diff --git a/cmd/admin-handlers.go b/cmd/admin-handlers.go index 4d4b64614..d59e9dd54 100644 --- a/cmd/admin-handlers.go +++ b/cmd/admin-handlers.go @@ -1260,7 +1260,12 @@ func (a adminAPIHandlers) ListGroups(w http.ResponseWriter, r *http.Request) { return } - groups := globalIAMSys.ListGroups() + groups, err := globalIAMSys.ListGroups() + if err != nil { + writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL) + return + } + body, err := json.Marshal(groups) if err != nil { writeErrorResponseJSON(ctx, w, toAdminAPIErr(ctx, err), r.URL) diff --git a/cmd/auth-handler.go b/cmd/auth-handler.go index 9ede51e38..ef1cf8031 100644 --- a/cmd/auth-handler.go +++ b/cmd/auth-handler.go @@ -203,6 +203,12 @@ func getClaimsFromToken(r *http.Request) (map[string]interface{}, error) { } if globalPolicyOPA == nil { + // If OPA is not set and if ldap claim key is set, + // allow the claim. + if _, ok := claims[ldapUser]; ok { + return claims, nil + } + // If OPA is not set, session token should // have a policy and its mandatory, reject // requests without policy claim. diff --git a/cmd/config-current.go b/cmd/config-current.go index e61aef2f0..11bcf52e2 100644 --- a/cmd/config-current.go +++ b/cmd/config-current.go @@ -1,5 +1,5 @@ /* - * MinIO Cloud Storage, (C) 2016, 2017, 2018 MinIO, Inc. + * MinIO Cloud Storage, (C) 2016-2019 MinIO, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -303,6 +303,12 @@ func (s *serverConfig) loadFromEnvs() { s.Policy.OPA.URL = opaArgs.URL s.Policy.OPA.AuthToken = opaArgs.AuthToken } + + var err error + s.LDAPServerConfig, err = newLDAPConfigFromEnv() + if err != nil { + logger.FatalIf(err, "Unable to parse LDAP configuration from env") + } } // TestNotificationTargets tries to establish connections to all notification diff --git a/cmd/config-versions.go b/cmd/config-versions.go index 5cc8d6a1d..bff7a3331 100644 --- a/cmd/config-versions.go +++ b/cmd/config-versions.go @@ -926,4 +926,6 @@ type serverConfigV33 struct { // Add new external policy enforcements here. } `json:"policy"` + + LDAPServerConfig ldapServerConfig `json:"ldapserverconfig"` } diff --git a/cmd/iam-etcd-store.go b/cmd/iam-etcd-store.go index ab35dbbcd..bd253c016 100644 --- a/cmd/iam-etcd-store.go +++ b/cmd/iam-etcd-store.go @@ -415,23 +415,35 @@ func (ies *IAMEtcdStore) loadAll(sys *IAMSys, objectAPI ObjectLayer) error { iamUserPolicyMap := make(map[string]MappedPolicy) iamGroupPolicyMap := make(map[string]MappedPolicy) - if err := ies.loadPolicyDocs(iamPolicyDocsMap); err != nil { - return err + isMinIOUsersSys := false + sys.RLock() + if sys.usersSysType == MinIOUsersSysType { + isMinIOUsersSys = true } - if err := ies.loadUsers(false, iamUsersMap); err != nil { + sys.RUnlock() + + if err := ies.loadPolicyDocs(iamPolicyDocsMap); err != nil { return err } - // load STS temp users into the same map + + // load STS temp users if err := ies.loadUsers(true, iamUsersMap); err != nil { return err } - if err := ies.loadGroups(iamGroupsMap); err != nil { - return err - } - if err := ies.loadMappedPolicies(false, false, iamUserPolicyMap); err != nil { - return err + if isMinIOUsersSys { + // load long term users + if err := ies.loadUsers(false, iamUsersMap); err != nil { + return err + } + if err := ies.loadGroups(iamGroupsMap); err != nil { + return err + } + if err := ies.loadMappedPolicies(false, false, iamUserPolicyMap); err != nil { + return err + } } + // load STS policy mappings into the same map if err := ies.loadMappedPolicies(true, false, iamUserPolicyMap); err != nil { return err diff --git a/cmd/iam-object-store.go b/cmd/iam-object-store.go index 73ff4efde..926c8e2d0 100644 --- a/cmd/iam-object-store.go +++ b/cmd/iam-object-store.go @@ -429,24 +429,33 @@ func (iamOS *IAMObjectStore) loadAll(sys *IAMSys, objectAPI ObjectLayer) error { iamUserPolicyMap := make(map[string]MappedPolicy) iamGroupPolicyMap := make(map[string]MappedPolicy) - if err := iamOS.loadPolicyDocs(iamPolicyDocsMap); err != nil { - return err + isMinIOUsersSys := false + sys.RLock() + if sys.usersSysType == MinIOUsersSysType { + isMinIOUsersSys = true } - if err := iamOS.loadUsers(false, iamUsersMap); err != nil { + sys.RUnlock() + + if err := iamOS.loadPolicyDocs(iamPolicyDocsMap); err != nil { return err } - // load STS temp users into the same map + // load STS temp users if err := iamOS.loadUsers(true, iamUsersMap); err != nil { return err } - if err := iamOS.loadGroups(iamGroupsMap); err != nil { - return err - } + if isMinIOUsersSys { + if err := iamOS.loadUsers(false, iamUsersMap); err != nil { + return err + } + if err := iamOS.loadGroups(iamGroupsMap); err != nil { + return err + } - if err := iamOS.loadMappedPolicies(false, false, iamUserPolicyMap); err != nil { - return err + if err := iamOS.loadMappedPolicies(false, false, iamUserPolicyMap); err != nil { + return err + } } - // load STS policy mappings into the same map + // load STS policy mappings if err := iamOS.loadMappedPolicies(true, false, iamUserPolicyMap); err != nil { return err } diff --git a/cmd/iam.go b/cmd/iam.go index b36cf9eeb..0dcee8106 100644 --- a/cmd/iam.go +++ b/cmd/iam.go @@ -30,6 +30,20 @@ import ( "github.com/minio/minio/pkg/madmin" ) +// UsersSysType - defines the type of users and groups system that is +// active on the server. +type UsersSysType string + +// Types of users configured in the server. +const ( + // This mode uses the internal users system in MinIO. + MinIOUsersSysType UsersSysType = "MinIOUsersSys" + + // This mode uses users and groups from a configured LDAP + // server. + LDAPUsersSysType UsersSysType = "LDAPUsersSys" +) + const ( // IAM configuration directory. iamConfigPrefix = minioConfigPrefix + "/iam" @@ -145,6 +159,9 @@ func newMappedPolicy(policy string) MappedPolicy { // IAMSys - config system. type IAMSys struct { sync.RWMutex + + usersSysType UsersSysType + // map of policy names to policy definitions iamPolicyDocsMap map[string]iampolicy.Policy // map of usernames to credentials @@ -463,6 +480,13 @@ func (sys *IAMSys) DeleteUser(accessKey string) error { return errServerNotInitialized } + sys.Lock() + defer sys.Unlock() + + if sys.usersSysType != MinIOUsersSysType { + return errIAMActionNotAllowed + } + // It is ok to ignore deletion error on the mapped policy sys.store.deleteMappedPolicy(accessKey, false, false) err := sys.store.deleteUserIdentity(accessKey, false) @@ -472,9 +496,6 @@ func (sys *IAMSys) DeleteUser(accessKey string) error { err = nil } - sys.Lock() - defer sys.Unlock() - delete(sys.iamUsersMap, accessKey) delete(sys.iamUserPolicyMap, accessKey) @@ -533,6 +554,10 @@ func (sys *IAMSys) ListUsers() (map[string]madmin.UserInfo, error) { sys.RLock() defer sys.RUnlock() + if sys.usersSysType != MinIOUsersSysType { + return nil, errIAMActionNotAllowed + } + for k, v := range sys.iamUsersMap { users[k] = madmin.UserInfo{ PolicyName: sys.iamUserPolicyMap[k].Policy, @@ -553,6 +578,12 @@ func (sys *IAMSys) GetUserInfo(name string) (u madmin.UserInfo, err error) { sys.RLock() defer sys.RUnlock() + if sys.usersSysType != MinIOUsersSysType { + return madmin.UserInfo{ + PolicyName: sys.iamUserPolicyMap[name].Policy, + }, nil + } + creds, found := sys.iamUsersMap[name] if !found { return u, errNoSuchUser @@ -580,6 +611,10 @@ func (sys *IAMSys) SetUserStatus(accessKey string, status madmin.AccountStatus) sys.Lock() defer sys.Unlock() + if sys.usersSysType != MinIOUsersSysType { + return errIAMActionNotAllowed + } + cred, ok := sys.iamUsersMap[accessKey] if !ok { return errNoSuchUser @@ -614,6 +649,10 @@ func (sys *IAMSys) SetUser(accessKey string, uinfo madmin.UserInfo) error { sys.Lock() defer sys.Unlock() + if sys.usersSysType != MinIOUsersSysType { + return errIAMActionNotAllowed + } + if err := sys.store.saveUserIdentity(accessKey, false, u); err != nil { return err } @@ -636,6 +675,10 @@ func (sys *IAMSys) SetUserSecretKey(accessKey string, secretKey string) error { sys.Lock() defer sys.Unlock() + if sys.usersSysType != MinIOUsersSysType { + return errIAMActionNotAllowed + } + cred, ok := sys.iamUsersMap[accessKey] if !ok { return errNoSuchUser @@ -675,6 +718,10 @@ func (sys *IAMSys) AddUsersToGroup(group string, members []string) error { sys.Lock() defer sys.Unlock() + if sys.usersSysType != MinIOUsersSysType { + return errIAMActionNotAllowed + } + // Validate that all members exist. for _, member := range members { _, ok := sys.iamUsersMap[member] @@ -729,6 +776,10 @@ func (sys *IAMSys) RemoveUsersFromGroup(group string, members []string) error { sys.Lock() defer sys.Unlock() + if sys.usersSysType != MinIOUsersSysType { + return errIAMActionNotAllowed + } + // Validate that all members exist. for _, member := range members { _, ok := sys.iamUsersMap[member] @@ -802,6 +853,10 @@ func (sys *IAMSys) SetGroupStatus(group string, enabled bool) error { sys.Lock() defer sys.Unlock() + if sys.usersSysType != MinIOUsersSysType { + return errIAMActionNotAllowed + } + if group == "" { return errInvalidArgument } @@ -830,6 +885,7 @@ func (sys *IAMSys) GetGroupDescription(group string) (gd madmin.GroupDesc, err e if err != nil { return gd, err } + // A group may be mapped to at most one policy. policy := "" if len(ps) > 0 { @@ -839,6 +895,13 @@ func (sys *IAMSys) GetGroupDescription(group string) (gd madmin.GroupDesc, err e sys.RLock() defer sys.RUnlock() + if sys.usersSysType != MinIOUsersSysType { + return madmin.GroupDesc{ + Name: group, + Policy: policy, + }, nil + } + gi, ok := sys.iamGroupsMap[group] if !ok { return gd, errNoSuchGroup @@ -852,15 +915,19 @@ func (sys *IAMSys) GetGroupDescription(group string) (gd madmin.GroupDesc, err e }, nil } -// ListGroups - lists groups -func (sys *IAMSys) ListGroups() (r []string) { +// ListGroups - lists groups. +func (sys *IAMSys) ListGroups() (r []string, err error) { sys.RLock() defer sys.RUnlock() + if sys.usersSysType != MinIOUsersSysType { + return nil, errIAMActionNotAllowed + } + for k := range sys.iamGroupsMap { r = append(r, k) } - return r + return r, nil } // PolicyDBSet - sets a policy for a user or group in the @@ -889,13 +956,16 @@ func (sys *IAMSys) policyDBSet(objectAPI ObjectLayer, name, policy string, isSTS if _, ok := sys.iamPolicyDocsMap[policy]; !ok { return errNoSuchPolicy } - if !isGroup { - if _, ok := sys.iamUsersMap[name]; !ok { - return errNoSuchUser - } - } else { - if _, ok := sys.iamGroupsMap[name]; !ok { - return errNoSuchGroup + + if sys.usersSysType == MinIOUsersSysType { + if !isGroup { + if _, ok := sys.iamUsersMap[name]; !ok { + return errNoSuchUser + } + } else { + if _, ok := sys.iamGroupsMap[name]; !ok { + return errNoSuchGroup + } } } @@ -980,6 +1050,63 @@ func (sys *IAMSys) policyDBGet(name string, isGroup bool) ([]string, error) { // which implements claims validation and verification other than // applying policies. func (sys *IAMSys) IsAllowedSTS(args iampolicy.Args) bool { + // If it is an LDAP request, check that user and group + // policies allow the request. + if userIface, ok := args.Claims[ldapUser]; ok { + var user string + if u, ok := userIface.(string); ok { + user = u + } else { + return false + } + + var groups []string + groupsVal := args.Claims[ldapGroups] + if g, ok := groupsVal.([]interface{}); ok { + for _, eachG := range g { + if eachGStr, ok := eachG.(string); ok { + groups = append(groups, eachGStr) + } + } + } else { + return false + } + + sys.RLock() + defer sys.RUnlock() + + // We look up the policy mapping directly to bypass + // users exists, group exists validations that do not + // apply here. + var policies []iampolicy.Policy + if policy, ok := sys.iamUserPolicyMap[user]; ok { + p, found := sys.iamPolicyDocsMap[policy.Policy] + if found { + policies = append(policies, p) + } + } + for _, group := range groups { + policy, ok := sys.iamGroupPolicyMap[group] + if !ok { + continue + } + p, found := sys.iamPolicyDocsMap[policy.Policy] + if found { + policies = append(policies, p) + } + } + if len(policies) == 0 { + return false + } + combinedPolicy := policies[0] + for i := 1; i < len(policies); i++ { + combinedPolicy.Statements = + append(combinedPolicy.Statements, + policies[i].Statements...) + } + return combinedPolicy.IsAllowed(args) + } + pname, ok := args.Claims[iampolicy.PolicyName] if !ok { // When claims are set, it should have a "policy" field. @@ -1155,7 +1282,20 @@ func (sys *IAMSys) removeGroupFromMembershipsMap(group string) { // NewIAMSys - creates new config system object. func NewIAMSys() *IAMSys { + // Check global server configuration to determine the type of + // users system configured. + + // The default users system + var utype UsersSysType + switch { + case globalServerConfig.LDAPServerConfig.ServerAddr != "": + utype = LDAPUsersSysType + default: + utype = MinIOUsersSysType + } + return &IAMSys{ + usersSysType: utype, iamUsersMap: make(map[string]auth.Credentials), iamPolicyDocsMap: make(map[string]iampolicy.Policy), iamUserPolicyMap: make(map[string]MappedPolicy), diff --git a/cmd/ldap-ops.go b/cmd/ldap-ops.go new file mode 100644 index 000000000..5ae511691 --- /dev/null +++ b/cmd/ldap-ops.go @@ -0,0 +1,178 @@ +/* + * MinIO Cloud Storage, (C) 2019 MinIO, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package cmd + +import ( + "crypto/tls" + "errors" + "fmt" + "log" + "os" + "regexp" + "time" + + ldap "gopkg.in/ldap.v3" +) + +const ( + defaultLDAPExpiry = time.Hour * 1 +) + +// ldapServerConfig contains server connectivity information. +type ldapServerConfig struct { + IsEnabled bool `json:"enabled"` + + // E.g. "ldap.minio.io:636" + ServerAddr string `json:"serverAddr"` + + // STS credentials expiry duration + STSExpiryDuration string `json:"stsExpiryDuration"` + stsExpiryDuration time.Duration // contains converted value + + // Skips TLS verification (for testing, not + // recommended in production). + SkipTLSVerify bool `json:"skipTLSverify"` + + // Format string for usernames + UsernameFormat string `json:"usernameFormat"` + + GroupSearchBaseDN string `json:"groupSearchBaseDN"` + GroupSearchFilter string `json:"groupSearchFilter"` + GroupNameAttribute string `json:"groupNameAttribute"` +} + +func (l *ldapServerConfig) Connect() (ldapConn *ldap.Conn, err error) { + if l == nil { + // Happens when LDAP is not configured. + return + } + if l.SkipTLSVerify { + ldapConn, err = ldap.DialTLS("tcp", l.ServerAddr, &tls.Config{InsecureSkipVerify: true}) + } else { + ldapConn, err = ldap.DialTLS("tcp", l.ServerAddr, &tls.Config{}) + } + return +} + +// newLDAPConfigFromEnv loads configuration from the environment +func newLDAPConfigFromEnv() (l ldapServerConfig, err error) { + if ldapServer, ok := os.LookupEnv("MINIO_IDENTITY_LDAP_SERVER_ADDR"); ok { + l.IsEnabled = true + l.ServerAddr = ldapServer + + if v := os.Getenv("MINIO_IDENTITY_LDAP_TLS_SKIP_VERIFY"); v == "true" { + l.SkipTLSVerify = true + } + + if v := os.Getenv("MINIO_IDENTITY_LDAP_STS_EXPIRY"); v != "" { + expDur, err := time.ParseDuration(v) + if err != nil { + return l, errors.New("LDAP expiry time err:" + err.Error()) + } + if expDur <= 0 { + return l, errors.New("LDAP expiry time has to be positive") + } + l.STSExpiryDuration = v + l.stsExpiryDuration = expDur + } else { + l.stsExpiryDuration = defaultLDAPExpiry + } + + if v := os.Getenv("MINIO_IDENTITY_LDAP_USERNAME_FORMAT"); v != "" { + subs := newSubstituter("username", "test") + if _, err := subs.substitute(v); err != nil { + return l, errors.New("Only username may be substituted in the username format") + } + l.UsernameFormat = v + } + + grpSearchFilter := os.Getenv("MINIO_IDENTITY_LDAP_GROUP_SEARCH_FILTER") + grpSearchNameAttr := os.Getenv("MINIO_IDENTITY_LDAP_GROUP_NAME_ATTRIBUTE") + grpSearchBaseDN := os.Getenv("MINIO_IDENTITY_LDAP_GROUP_SEARCH_BASE_DN") + + // Either all group params must be set or none must be set. + allNotSet := grpSearchFilter == "" && grpSearchNameAttr == "" && grpSearchBaseDN == "" + allSet := grpSearchFilter != "" && grpSearchNameAttr != "" && grpSearchBaseDN != "" + if !allNotSet && !allSet { + return l, errors.New("All group related parameters must be set") + } + + if allSet { + subs := newSubstituter("username", "test", "usernamedn", "test2") + if _, err := subs.substitute(grpSearchFilter); err != nil { + return l, errors.New("Only username and usernamedn may be substituted in the group search filter string") + } + l.GroupSearchFilter = grpSearchFilter + + l.GroupNameAttribute = grpSearchNameAttr + + subs = newSubstituter("username", "test", "usernamedn", "test2") + if _, err := subs.substitute(grpSearchBaseDN); err != nil { + return l, errors.New("Only username and usernamedn may be substituted in the base DN string") + } + l.GroupSearchBaseDN = grpSearchBaseDN + } + } + return +} + +// substituter - This type is to allow restricted runtime +// substitutions of variables in LDAP configuration items during +// runtime. +type substituter struct { + vals map[string]string +} + +// newSubstituter - sets up the substituter for usage, for e.g.: +// +// subber := newSubstituter("username", "john") +func newSubstituter(v ...string) substituter { + if len(v)%2 != 0 { + log.Fatal("Need an even number of arguments") + } + vals := make(map[string]string) + for i := 0; i < len(v); i += 2 { + vals[v[i]] = v[i+1] + } + return substituter{vals: vals} +} + +// substitute - performs substitution on the given string `t`. Returns +// an error if there are any variables in the input that do not have +// values in the substituter. E.g.: +// +// subber.substitute("uid=${username},cn=users,dc=example,dc=com") +// +// returns "uid=john,cn=users,dc=example,dc=com" +// +// whereas: +// +// subber.substitute("uid=${usernamedn}") +// +// returns an error. +func (s *substituter) substitute(t string) (string, error) { + for k, v := range s.vals { + re := regexp.MustCompile(fmt.Sprintf(`\$\{%s\}`, k)) + t = re.ReplaceAllLiteralString(t, v) + } + // Check if all requested substitutions have been made. + re := regexp.MustCompile(`\$\{.*\}`) + if re.MatchString(t) { + return "", errors.New("unsupported substitution requested") + } + return t, nil +} diff --git a/cmd/sts-datatypes.go b/cmd/sts-datatypes.go index fae76ab28..0acdca4c7 100644 --- a/cmd/sts-datatypes.go +++ b/cmd/sts-datatypes.go @@ -174,3 +174,19 @@ type ClientGrantsResult struct { // provider as the token's sub (Subject) claim. SubjectFromToken string `xml:",omitempty"` } + +// AssumeRoleWithLDAPResponse contains the result of successful +// AssumeRoleWithLDAPIdentity request +type AssumeRoleWithLDAPResponse struct { + XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleWithClientGrantsResponse" json:"-"` + Result LDAPIdentityResult `xml:"AssumeRoleWithLDAPIdentity"` + ResponseMetadata struct { + RequestID string `xml:"RequestId,omitempty"` + } `xml:"ResponseMetadata,omitempty"` +} + +// LDAPIdentityResult - contains credentials for a successful +// AssumeRoleWithLDAPIdentity request. +type LDAPIdentityResult struct { + Credentials auth.Credentials `xml:",omitempty"` +} diff --git a/cmd/sts-handlers.go b/cmd/sts-handlers.go index aa56b06ed..c8dff2980 100644 --- a/cmd/sts-handlers.go +++ b/cmd/sts-handlers.go @@ -30,6 +30,7 @@ import ( iampolicy "github.com/minio/minio/pkg/iam/policy" "github.com/minio/minio/pkg/iam/validator" "github.com/minio/minio/pkg/wildcard" + ldap "gopkg.in/ldap.v3" ) const ( @@ -39,9 +40,14 @@ const ( // STS API action constants clientGrants = "AssumeRoleWithClientGrants" webIdentity = "AssumeRoleWithWebIdentity" + ldapIdentity = "AssumeRoleWithLDAPIdentity" assumeRole = "AssumeRole" stsRequestBodyLimit = 10 * (1 << 20) // 10 MiB + + // LDAP claim keys + ldapUser = "ldapUser" + ldapGroups = "ldapGroups" ) // stsAPIHandlers implements and provides http handlers for AWS STS API. @@ -82,6 +88,12 @@ func registerSTSRouter(router *mux.Router) { Queries("Version", stsAPIVersion). Queries("WebIdentityToken", "{Token:.*}") + // AssumeRoleWithLDAPIdentity + stsRouter.Methods("POST").HandlerFunc(httpTraceAll(sts.AssumeRoleWithLDAPIdentity)). + Queries("Action", ldapIdentity). + Queries("Version", stsAPIVersion). + Queries("LDAPUsername", "{LDAPUsername:.*}"). + Queries("LDAPPassword", "{LDAPPassword:.*}") } func checkAssumeRoleAuth(ctx context.Context, r *http.Request) (user auth.Credentials, stsErr STSErrorCode) { @@ -411,3 +423,138 @@ func (sts *stsAPIHandlers) AssumeRoleWithWebIdentity(w http.ResponseWriter, r *h func (sts *stsAPIHandlers) AssumeRoleWithClientGrants(w http.ResponseWriter, r *http.Request) { sts.AssumeRoleWithJWT(w, r) } + +// AssumeRoleWithLDAPIdentity - implements user auth against LDAP server +func (sts *stsAPIHandlers) AssumeRoleWithLDAPIdentity(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "AssumeRoleWithLDAPIdentity") + + // Parse the incoming form data. + if err := r.ParseForm(); err != nil { + logger.LogIf(ctx, err) + writeSTSErrorResponse(w, stsErrCodes.ToSTSErr(ErrSTSInvalidParameterValue)) + return + } + + if r.Form.Get("Version") != stsAPIVersion { + logger.LogIf(ctx, fmt.Errorf("Invalid STS API version %s, expecting %s", r.Form.Get("Version"), stsAPIVersion)) + writeSTSErrorResponse(w, stsErrCodes.ToSTSErr(ErrSTSMissingParameter)) + return + } + + action := r.Form.Get("Action") + switch action { + case ldapIdentity: + default: + logger.LogIf(ctx, fmt.Errorf("Unsupported action %s", action)) + writeSTSErrorResponse(w, stsErrCodes.ToSTSErr(ErrSTSInvalidParameterValue)) + return + } + + ctx = newContext(r, w, action) + defer logger.AuditLog(w, r, action, nil) + + ldapUsername := r.Form.Get("LDAPUsername") + ldapPassword := r.Form.Get("LDAPPassword") + + if ldapUsername == "" || ldapPassword == "" { + writeSTSErrorResponse(w, stsErrCodes.ToSTSErr(ErrSTSMissingParameter)) + return + } + + ldapConn, err := globalServerConfig.LDAPServerConfig.Connect() + if err != nil { + logger.LogIf(ctx, fmt.Errorf("LDAP server connection failure: %v", err)) + writeSTSErrorResponse(w, stsErrCodes.ToSTSErr(ErrSTSInvalidParameterValue)) + return + } + if ldapConn == nil { + logger.LogIf(ctx, fmt.Errorf("LDAP server not configured: %v", err)) + writeSTSErrorResponse(w, stsErrCodes.ToSTSErr(ErrSTSInvalidParameterValue)) + return + } + + usernameSubs := newSubstituter("username", ldapUsername) + // We ignore error below as we already validated the username + // format string at startup. + usernameDN, _ := usernameSubs.substitute(globalServerConfig.LDAPServerConfig.UsernameFormat) + // Bind with user credentials to validate the password + err = ldapConn.Bind(usernameDN, ldapPassword) + if err != nil { + logger.LogIf(ctx, fmt.Errorf("LDAP authentication failure: %v", err)) + writeSTSErrorResponse(w, stsErrCodes.ToSTSErr(ErrSTSInvalidParameterValue)) + return + } + + groups := []string{} + if globalServerConfig.LDAPServerConfig.GroupSearchFilter != "" { + // Verified user credentials. Now we find the groups they are + // a member of. + searchSubs := newSubstituter( + "username", ldapUsername, + "usernamedn", usernameDN, + ) + // We ignore error below as we already validated the search string + // at startup. + groupSearchFilter, _ := searchSubs.substitute(globalServerConfig.LDAPServerConfig.GroupSearchFilter) + baseDN, _ := searchSubs.substitute(globalServerConfig.LDAPServerConfig.GroupSearchBaseDN) + searchRequest := ldap.NewSearchRequest( + baseDN, + ldap.ScopeWholeSubtree, ldap.NeverDerefAliases, 0, 0, false, + groupSearchFilter, + []string{globalServerConfig.LDAPServerConfig.GroupNameAttribute}, + nil, + ) + + sr, err := ldapConn.Search(searchRequest) + if err != nil { + logger.LogIf(ctx, fmt.Errorf("LDAP search failure: %v", err)) + writeSTSErrorResponse(w, stsErrCodes.ToSTSErr(ErrSTSInvalidParameterValue)) + return + } + for _, entry := range sr.Entries { + // We only queried one attribute, so we only look up + // the first one. + groups = append(groups, entry.Attributes[0].Values...) + } + } + expiryDur := globalServerConfig.LDAPServerConfig.stsExpiryDuration + m := map[string]interface{}{ + "exp": UTCNow().Add(expiryDur).Unix(), + "ldapUser": ldapUsername, + "ldapGroups": groups, + } + + secret := globalServerConfig.GetCredential().SecretKey + cred, err := auth.GetNewCredentialsWithMetadata(m, secret) + if err != nil { + logger.LogIf(ctx, err) + writeSTSErrorResponse(w, stsErrCodes.ToSTSErr(ErrSTSInternalError)) + return + } + + policyName := "" + // Set the newly generated credentials. + if err = globalIAMSys.SetTempUser(cred.AccessKey, cred, policyName); err != nil { + logger.LogIf(ctx, err) + writeSTSErrorResponse(w, stsErrCodes.ToSTSErr(ErrSTSInternalError)) + return + } + + // Notify all other MinIO peers to reload temp users + for _, nerr := range globalNotificationSys.LoadUser(cred.AccessKey, true) { + if nerr.Err != nil { + logger.GetReqInfo(ctx).SetTags("peerAddress", nerr.Host.String()) + logger.LogIf(ctx, nerr.Err) + } + } + + ldapIdentityResponse := &AssumeRoleWithLDAPResponse{ + Result: LDAPIdentityResult{ + Credentials: cred, + }, + } + ldapIdentityResponse.ResponseMetadata.RequestID = w.Header().Get(xhttp.AmzRequestID) + encodedSuccessResponse := encodeResponse(ldapIdentityResponse) + + writeSuccessResponseXML(w, encodedSuccessResponse) +} diff --git a/cmd/test-utils_test.go b/cmd/test-utils_test.go index b6a0c7275..a591a3aec 100644 --- a/cmd/test-utils_test.go +++ b/cmd/test-utils_test.go @@ -1932,18 +1932,18 @@ func ExecObjectLayerAPITest(t *testing.T, objAPITest objAPITestType, endpoints [ t.Fatalf("Initialization of API handler tests failed: %s", err) } - globalIAMSys = NewIAMSys() - globalIAMSys.Init(objLayer) - - globalPolicySys = NewPolicySys() - globalPolicySys.Init(objLayer) - // initialize the server and obtain the credentials and root. // credentials are necessary to sign the HTTP request. if err = newTestConfig(globalMinioDefaultRegion, objLayer); err != nil { t.Fatalf("Unable to initialize server config. %s", err) } + globalIAMSys = NewIAMSys() + globalIAMSys.Init(objLayer) + + globalPolicySys = NewPolicySys() + globalPolicySys.Init(objLayer) + credentials := globalServerConfig.GetCredential() // Executing the object layer tests for single node setup. diff --git a/cmd/typed-errors.go b/cmd/typed-errors.go index 82ae36b49..f16706edb 100644 --- a/cmd/typed-errors.go +++ b/cmd/typed-errors.go @@ -90,5 +90,8 @@ var errGroupNotEmpty = errors.New("Specified group is not empty - cannot remove // error returned in IAM subsystem when policy doesn't exist. var errNoSuchPolicy = errors.New("Specified canned policy does not exist") +// error returned in IAM subsystem when an external users systems is configured. +var errIAMActionNotAllowed = errors.New("Specified IAM action is not allowed under the current configuration") + // error returned when access is denied. var errAccessDenied = errors.New("Do not have enough permissions to access this resource") diff --git a/docs/sts/ldap.go b/docs/sts/ldap.go new file mode 100644 index 000000000..2c993d8e5 --- /dev/null +++ b/docs/sts/ldap.go @@ -0,0 +1,63 @@ +// +build ignore + +/* + * MinIO Cloud Storage, (C) 2019 MinIO, Inc. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package main + +import ( + "fmt" + "log" + + miniogo "github.com/minio/minio-go" + cr "github.com/minio/minio-go/pkg/credentials" +) + +var ( + // LDAP integrated Minio endpoint + stsEndpoint = "http://localhost:9000" + + // LDAP credentials + ldapUsername = "ldapuser" + ldapPassword = "ldapsecret" +) + +func main() { + + // If client machine is configured as a Kerberos client, just + // pass nil instead of `getKrbConfig()` below. + li, err := cr.NewLDAPIdentity(stsEndpoint, ldapUsername, ldapPassword) + if err != nil { + log.Fatalf("INIT Err: %v", err) + } + + v, err := li.Get() + if err != nil { + log.Fatalf("GET Err: %v", err) + } + fmt.Printf("%#v\n", v) + + minioClient, err := miniogo.NewWithCredentials("localhost:9000", li, false, "") + if err != nil { + log.Fatalln(err) + } + + fmt.Println("Calling list buckets with temp creds:") + b, err := minioClient.ListBuckets() + if err != nil { + log.Fatalln(err) + } + fmt.Println(b) +} diff --git a/docs/sts/ldap.md b/docs/sts/ldap.md new file mode 100644 index 000000000..4bb04cf51 --- /dev/null +++ b/docs/sts/ldap.md @@ -0,0 +1,92 @@ +# MinIO LDAP Integration + +MinIO provides a custom STS API that allows integration with LDAP +based corporate environments. The flow is as follows: + +1. User provides their LDAP username and password to the STS API. +2. MinIO logs-in to the LDAP server as the user - if the login + succeeds the user is authenticated. +3. MinIO then queries the LDAP server for a list of groups that the + user is a member of. + - This is done via a customizable LDAP search query. +4. MinIO then generates temporary credentials for the user storing the + list of groups in a cryptographically secure session token. The + temporary access key, secret key and session token are returned to + the user. +5. The user can now use these credentials to make requests to the + MinIO server. + +The administrator will associate IAM access policies with each group +and if required with the user too. The MinIO server then evaluates +applicable policies on a user (these are the policies associated with +the groups along with the policy on the user if any) to check if the +request should be allowed or denied. + +## Configuring LDAP on MinIO + +LDAP configuration is designed to be simple for the MinIO +administrator. + +The full path of a user DN (Distinguished Name) +(e.g. `uid=johnwick,cn=users,cn=accounts,dc=minio,dc=io`) is +configured as a format string in the +**MINIO_IDENTITY_LDAP_USERNAME_FORMAT** environment variable. This +allows an LDAP user to not specify this whole string in the LDAP STS +API. Instead the user only needs to specify the username portion +(i.e. `johnwick` in this example) that will be substituted into the +format string configured on the server. + +MinIO can be configured to find the groups of a user from LDAP by +specifying the **MINIO_IDENTITY_LDAP_GROUP_SEARCH_FILTER** and +**MINIO_IDENTITY_LDAP_GROUP_NAME_ATTRIBUTE** environment +variables. When a user logs in via the STS API, the MinIO server +queries the LDAP server with the given search filter and extracts the +given attribute from the search results. These values represent the +groups that the user is a member of. On each access MinIO applies the +IAM policies attached to these groups in MinIO. + +LDAP is configured via the following environment variables: + +| Variable | Required? | Purpose | +|----------------------------------------------|---------------------------|-----------------------------------------------------| +| **MINIO_IDENTITY_LDAP_SERVER_ADDR** | **YES** | LDAP server address | +| **MINIO_IDENTITY_LDAP_USERNAME_FORMAT** | **YES** | Format of full username DN | +| **MINIO_IDENTITY_LDAP_GROUP_SEARCH_BASE_DN** | **NO** | Base DN in LDAP hierarchy to use in search requests | +| **MINIO_IDENTITY_LDAP_GROUP_SEARCH_FILTER** | **NO** | Search filter to find groups of a user | +| **MINIO_IDENTITY_LDAP_GROUP_NAME_ATTRIBUTE** | **NO** | Attribute of search results to use as group name | +| **MINIO_IDENTITY_LDAP_STS_EXPIRY_DURATION** | **NO** (default: "1h") | STS credentials validity duration | +| **MINIO_IDENTITY_LDAP_TLS_SKIP_VERIFY** | **NO** (default: "false") | Disable TLS certificate verification | + +Please note that MinIO will only access the LDAP server over TLS. + +An example setup for development or experimentation: + +``` shell +export MINIO_IDENTITY_LDAP_SERVER_ADDR=myldapserver.com:636 +export MINIO_IDENTITY_LDAP_USERNAME_FORMAT="uid=${username},cn=accounts,dc=myldapserver,dc=com" +export MINIO_IDENTITY_LDAP_GROUP_SEARCH_BASE_DN="dc=myldapserver,dc=com" +export MINIO_IDENTITY_LDAP_GROUP_SEARCH_FILTER="(&(objectclass=groupOfNames)(member=${usernamedn}))" +export MINIO_IDENTITY_LDAP_GROUP_NAME_ATTRIBUTE="cn" +export MINIO_IDENTITY_LDAP_STS_EXPIRY_DURATION=60 +export MINIO_IDENTITY_LDAP_TLS_SKIP_VERIFY=true +``` + +### Variable substitution in LDAP configuration strings + +In the configuration values described above, some values support +runtime substitutions. The substitution syntax is simply +`${variable}` - this substring is replaced with the (string) value of +`variable`. The following substitutions will be available: + +| Variable | Example Runtime Value | Description | +|--------------|------------------------------------------------|----------------------------------------------------------------------------------------------------------------------------------------------| +| *username* | "james" | The LDAP username of a user. | +| *usernamedn* | "uid=james,cn=accounts,dc=myldapserver,dc=com" | The LDAP username DN of a user. This is constructed from the LDAP user DN format string provided to the server and the actual LDAP username. | + +The **MINIO_IDENTITY_LDAP_USERNAME_FORMAT** environment variable +supports substitution of the *username* variable only. + +The **MINIO_IDENTITY_LDAP_GROUP_SEARCH_FILTER** and +**MINIO_IDENTITY_LDAP_GROUP_SEARCH_BASE_DN** environment variables +support substitution of the *username* and *usernamedn* variables +only. diff --git a/go.mod b/go.mod index 3977787c0..3e6de2dc7 100644 --- a/go.mod +++ b/go.mod @@ -87,6 +87,7 @@ require ( golang.org/x/sys v0.0.0-20190904154756-749cb33beabd google.golang.org/api v0.4.0 gopkg.in/Shopify/sarama.v1 v1.20.0 + gopkg.in/ldap.v3 v3.0.3 gopkg.in/olivere/elastic.v5 v5.0.80 gopkg.in/yaml.v2 v2.2.2 ) diff --git a/go.sum b/go.sum index 7ea9e8ab8..2c6818119 100644 --- a/go.sum +++ b/go.sum @@ -782,6 +782,7 @@ gopkg.in/Shopify/sarama.v1 v1.20.0 h1:DrCuMOhmuaUwb5o4aL9JJnW+whbEnuuL6AZ99ySMoQ gopkg.in/Shopify/sarama.v1 v1.20.0/go.mod h1:AxnvoaevB2nBjNK17cG61A3LleFcWFwVBHBt+cot4Oc= gopkg.in/VividCortex/ewma.v1 v1.1.1/go.mod h1:TekXuFipeiHWiAlO1+wSS23vTcyFau5u3rxXUSXj710= gopkg.in/alecthomas/kingpin.v2 v2.2.6/go.mod h1:FMv+mEhP44yOT+4EoQTLFTRgOQ1FBLkstjWtayDeSgw= +gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d h1:TxyelI5cVkbREznMhfzycHdkp5cLA7DpE+GKjSslYhM= gopkg.in/asn1-ber.v1 v1.0.0-20181015200546-f715ec2f112d/go.mod h1:cuepJuh7vyXfUyUwEgHQXw849cJrilpS5NeIjOWESAw= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127 h1:qIbj1fsPNlZgppZ+VLlY7N33q108Sa+fhmuc+sWQYwY= @@ -811,6 +812,8 @@ gopkg.in/jcmturner/gokrb5.v5 v5.3.0/go.mod h1:oQz8Wc5GsctOTgCVyKad1Vw4TCWz5G6gfI gopkg.in/jcmturner/rpc.v0 v0.0.2/go.mod h1:NzMq6cRzR9lipgw7WxRBHNx5N8SifBuaCQsOT1kWY/E= gopkg.in/jcmturner/rpc.v1 v1.1.0 h1:QHIUxTX1ISuAv9dD2wJ9HWQVuWDX/Zc0PfeC2tjc4rU= gopkg.in/jcmturner/rpc.v1 v1.1.0/go.mod h1:YIdkC4XfD6GXbzje11McwsDuOlZQSb9W4vfLvuNnlv8= +gopkg.in/ldap.v3 v3.0.3 h1:YKRHW/2sIl05JsCtx/5ZuUueFuJyoj/6+DGXe3wp6ro= +gopkg.in/ldap.v3 v3.0.3/go.mod h1:oxD7NyBuxchC+SgJDE1Q5Od05eGt29SDQVBmV+HYbzw= gopkg.in/mattn/go-colorable.v0 v0.1.0/go.mod h1:BVJlBXzARQxdi3nZo6f6bnl5yR20/tOL6p+V0KejgSY= gopkg.in/mattn/go-isatty.v0 v0.0.4/go.mod h1:wt691ab7g0X4ilKZNmMII3egK0bTxl37fEn/Fwbd8gc= gopkg.in/mattn/go-runewidth.v0 v0.0.4/go.mod h1:BmXejnxvhwdaATwiJbB1vZ2dtXkQKZGu9yLFCZb4msQ=