diff --git a/cmd/admin-handlers.go b/cmd/admin-handlers.go index ace5e51bf..61e714702 100644 --- a/cmd/admin-handlers.go +++ b/cmd/admin-handlers.go @@ -36,6 +36,7 @@ import ( "github.com/minio/minio/cmd/logger" "github.com/minio/minio/pkg/auth" "github.com/minio/minio/pkg/handlers" + "github.com/minio/minio/pkg/iam/policy" "github.com/minio/minio/pkg/madmin" "github.com/minio/minio/pkg/quick" "github.com/tidwall/gjson" @@ -43,7 +44,7 @@ import ( ) const ( - maxConfigJSONSize = 256 * 1024 // 256KiB + maxEConfigJSONSize = 262272 ) // Type-safe query params. @@ -597,7 +598,7 @@ func (a adminAPIHandlers) GetConfigHandler(w http.ResponseWriter, r *http.Reques } password := config.GetCredential().SecretKey - econfigData, err := madmin.EncryptServerConfigData(password, configData) + econfigData, err := madmin.EncryptData(password, configData) if err != nil { logger.LogIf(ctx, err) writeErrorResponseJSON(w, toAdminAPIErrCode(err), r.URL) @@ -681,7 +682,7 @@ func (a adminAPIHandlers) GetConfigKeysHandler(w http.ResponseWriter, r *http.Re } password := config.GetCredential().SecretKey - econfigData, err := madmin.EncryptServerConfigData(password, []byte(newConfigStr)) + econfigData, err := madmin.EncryptData(password, []byte(newConfigStr)) if err != nil { logger.LogIf(ctx, err) writeErrorResponseJSON(w, toAdminAPIErrCode(err), r.URL) @@ -702,9 +703,75 @@ func toAdminAPIErrCode(err error) APIErrorCode { } } -// SetConfigHandler - PUT /minio/admin/v1/config -func (a adminAPIHandlers) SetConfigHandler(w http.ResponseWriter, r *http.Request) { - ctx := newContext(r, w, "SetConfigHandler") +// RemoveUser - DELETE /minio/admin/v1/remove-user?accessKey= +func (a adminAPIHandlers) RemoveUser(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "RemoveUser") + + // Get current object layer instance. + objectAPI := newObjectLayerFn() + if objectAPI == nil { + writeErrorResponseJSON(w, ErrServerNotInitialized, r.URL) + return + } + + // Validate request signature. + adminAPIErr := checkAdminRequestAuthType(r, "") + if adminAPIErr != ErrNone { + writeErrorResponseJSON(w, adminAPIErr, r.URL) + return + } + + // Deny if WORM is enabled + if globalWORMEnabled { + writeErrorResponseJSON(w, ErrMethodNotAllowed, r.URL) + return + } + + vars := mux.Vars(r) + accessKey := vars["accessKey"] + if err := globalIAMSys.DeleteUser(accessKey); err != nil { + logger.LogIf(ctx, err) + writeErrorResponseJSON(w, ErrInternalError, r.URL) + return + } +} + +// RemoveUserPolicy - DELETE /minio/admin/v1/remove-user-policy?accessKey= +func (a adminAPIHandlers) RemoveUserPolicy(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "RemoveUserPolicy") + + // Get current object layer instance. + objectAPI := newObjectLayerFn() + if objectAPI == nil { + writeErrorResponseJSON(w, ErrServerNotInitialized, r.URL) + return + } + + // Validate request signature. + adminAPIErr := checkAdminRequestAuthType(r, "") + if adminAPIErr != ErrNone { + writeErrorResponseJSON(w, adminAPIErr, r.URL) + return + } + + // Deny if WORM is enabled + if globalWORMEnabled { + writeErrorResponseJSON(w, ErrMethodNotAllowed, r.URL) + return + } + + vars := mux.Vars(r) + accessKey := vars["accessKey"] + if err := globalIAMSys.DeletePolicy(accessKey); err != nil { + logger.LogIf(ctx, err) + writeErrorResponseJSON(w, ErrInternalError, r.URL) + return + } +} + +// AddUser - PUT /minio/admin/v1/add-user?accessKey= +func (a adminAPIHandlers) AddUser(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "AddUser") // Get current object layer instance. objectAPI := newObjectLayerFn() @@ -726,22 +793,137 @@ func (a adminAPIHandlers) SetConfigHandler(w http.ResponseWriter, r *http.Reques return } - // Read configuration bytes from request body. - configBuf := make([]byte, maxConfigJSONSize+1) - n, err := io.ReadFull(r.Body, configBuf) - if err == nil { + vars := mux.Vars(r) + accessKey := vars["accessKey"] + + // Custom IAM policies not allowed for admin user. + if accessKey == globalServerConfig.GetCredential().AccessKey { + writeErrorResponse(w, ErrInvalidRequest, r.URL) + return + } + + if r.ContentLength > maxEConfigJSONSize || r.ContentLength == -1 { // More than maxConfigSize bytes were available writeErrorResponseJSON(w, ErrAdminConfigTooLarge, r.URL) return } - if err != io.ErrUnexpectedEOF { + + password := globalServerConfig.GetCredential().SecretKey + configBytes, err := madmin.DecryptData(password, io.LimitReader(r.Body, r.ContentLength)) + if err != nil { logger.LogIf(ctx, err) - writeErrorResponseJSON(w, toAPIErrorCode(err), r.URL) + writeErrorResponseJSON(w, ErrAdminConfigBadJSON, r.URL) + return + } + var uinfo madmin.UserInfo + if err = json.Unmarshal(configBytes, &uinfo); err != nil { + logger.LogIf(ctx, err) + writeErrorResponseJSON(w, ErrAdminConfigBadJSON, r.URL) + return + } + if err = globalIAMSys.SetUser(accessKey, uinfo); err != nil { + logger.LogIf(ctx, err) + writeErrorResponseJSON(w, ErrInternalError, r.URL) + return + } +} + +// AddUserPolicy - PUT /minio/admin/v1/add-user-policy?accessKey= +func (a adminAPIHandlers) AddUserPolicy(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "AddUserPolicy") + + // Get current object layer instance. + objectAPI := newObjectLayerFn() + if objectAPI == nil { + writeErrorResponseJSON(w, ErrServerNotInitialized, r.URL) + return + } + + vars := mux.Vars(r) + accessKey := vars["accessKey"] + + // Validate request signature. + adminAPIErr := checkAdminRequestAuthType(r, "") + if adminAPIErr != ErrNone { + writeErrorResponseJSON(w, adminAPIErr, r.URL) + return + } + + // Deny if WORM is enabled + if globalWORMEnabled { + writeErrorResponseJSON(w, ErrMethodNotAllowed, r.URL) + return + } + + // Custom IAM policies not allowed for admin user. + if accessKey == globalServerConfig.GetCredential().AccessKey { + writeErrorResponse(w, ErrInvalidRequest, r.URL) + return + } + + // Error out if Content-Length is missing. + if r.ContentLength <= 0 { + writeErrorResponse(w, ErrMissingContentLength, r.URL) + return + } + + // Error out if Content-Length is beyond allowed size. + if r.ContentLength > maxBucketPolicySize { + writeErrorResponse(w, ErrEntityTooLarge, r.URL) + return + } + + iamPolicy, err := iampolicy.ParseConfig(io.LimitReader(r.Body, r.ContentLength)) + if err != nil { + writeErrorResponse(w, ErrMalformedPolicy, r.URL) + return + } + + // Version in policy must not be empty + if iamPolicy.Version == "" { + writeErrorResponse(w, ErrMalformedPolicy, r.URL) + return + } + + if err = globalIAMSys.SetPolicy(accessKey, *iamPolicy); err != nil { + logger.LogIf(ctx, err) + writeErrorResponse(w, ErrInternalError, r.URL) + return + } +} + +// SetConfigHandler - PUT /minio/admin/v1/config +func (a adminAPIHandlers) SetConfigHandler(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "SetConfigHandler") + + // Get current object layer instance. + objectAPI := newObjectLayerFn() + if objectAPI == nil { + writeErrorResponseJSON(w, ErrServerNotInitialized, r.URL) + return + } + + // Validate request signature. + adminAPIErr := checkAdminRequestAuthType(r, "") + if adminAPIErr != ErrNone { + writeErrorResponseJSON(w, adminAPIErr, r.URL) + return + } + + // Deny if WORM is enabled + if globalWORMEnabled { + writeErrorResponseJSON(w, ErrMethodNotAllowed, r.URL) + return + } + + if r.ContentLength > maxEConfigJSONSize || r.ContentLength == -1 { + // More than maxConfigSize bytes were available + writeErrorResponseJSON(w, ErrAdminConfigTooLarge, r.URL) return } password := globalServerConfig.GetCredential().SecretKey - configBytes, err := madmin.DecryptServerConfigData(password, bytes.NewReader(configBuf[:n])) + configBytes, err := madmin.DecryptData(password, io.LimitReader(r.Body, r.ContentLength)) if err != nil { logger.LogIf(ctx, err) writeErrorResponseJSON(w, ErrAdminConfigBadJSON, r.URL) @@ -757,8 +939,7 @@ func (a adminAPIHandlers) SetConfigHandler(w http.ResponseWriter, r *http.Reques } var config serverConfig - err = json.Unmarshal(configBytes, &config) - if err != nil { + if err = json.Unmarshal(configBytes, &config); err != nil { logger.LogIf(ctx, err) writeCustomErrorResponseJSON(w, ErrAdminConfigBadJSON, err.Error(), r.URL) return @@ -860,7 +1041,7 @@ func (a adminAPIHandlers) SetConfigKeysHandler(w http.ResponseWriter, r *http.Re writeErrorResponseJSON(w, ErrAdminConfigBadJSON, r.URL) return } - elem, dErr := madmin.DecryptServerConfigData(password, bytes.NewBuffer([]byte(encryptedElem))) + elem, dErr := madmin.DecryptData(password, bytes.NewBuffer([]byte(encryptedElem))) if dErr != nil { logger.LogIf(ctx, dErr) writeErrorResponseJSON(w, ErrAdminConfigBadJSON, r.URL) @@ -920,11 +1101,10 @@ func (a adminAPIHandlers) SetConfigKeysHandler(w http.ResponseWriter, r *http.Re writeSuccessResponseHeadersOnly(w) } -// UpdateCredsHandler - POST /minio/admin/v1/config/credential +// UpdateAdminCredsHandler - POST /minio/admin/v1/config/credential // ---------- -// Update credentials in a minio server. In a distributed setup, -// update all the servers in the cluster. -func (a adminAPIHandlers) UpdateCredentialsHandler(w http.ResponseWriter, +// Update admin credentials in a minio server +func (a adminAPIHandlers) UpdateAdminCredentialsHandler(w http.ResponseWriter, r *http.Request) { ctx := newContext(r, w, "UpdateCredentialsHandler") @@ -938,7 +1118,7 @@ func (a adminAPIHandlers) UpdateCredentialsHandler(w http.ResponseWriter, // Avoid setting new credentials when they are already passed // by the environment. Deny if WORM is enabled. - if globalIsEnvCreds { + if globalIsEnvCreds || globalWORMEnabled { writeErrorResponseJSON(w, ErrMethodNotAllowed, r.URL) return } @@ -950,22 +1130,14 @@ func (a adminAPIHandlers) UpdateCredentialsHandler(w http.ResponseWriter, return } - // Read configuration bytes from request body. - configBuf := make([]byte, maxConfigJSONSize+1) - n, err := io.ReadFull(r.Body, configBuf) - if err == nil { + if r.ContentLength > maxEConfigJSONSize || r.ContentLength == -1 { // More than maxConfigSize bytes were available writeErrorResponseJSON(w, ErrAdminConfigTooLarge, r.URL) return } - if err != io.ErrUnexpectedEOF { - logger.LogIf(ctx, err) - writeErrorResponseJSON(w, toAPIErrorCode(err), r.URL) - return - } password := globalServerConfig.GetCredential().SecretKey - configBytes, err := madmin.DecryptServerConfigData(password, bytes.NewReader(configBuf[:n])) + configBytes, err := madmin.DecryptData(password, io.LimitReader(r.Body, r.ContentLength)) if err != nil { logger.LogIf(ctx, err) writeErrorResponseJSON(w, ErrAdminConfigBadJSON, r.URL) @@ -993,6 +1165,9 @@ func (a adminAPIHandlers) UpdateCredentialsHandler(w http.ResponseWriter, // Update local credentials in memory. globalServerConfig.SetCredential(creds) + // Set active creds. + globalActiveCred = creds + if err = saveServerConfig(ctx, objectAPI, globalServerConfig); err != nil { writeErrorResponseJSON(w, toAdminAPIErrCode(err), r.URL) return diff --git a/cmd/admin-handlers_test.go b/cmd/admin-handlers_test.go index faf73c249..fc92975d4 100644 --- a/cmd/admin-handlers_test.go +++ b/cmd/admin-handlers_test.go @@ -38,172 +38,184 @@ import ( var ( configJSON = []byte(`{ - "version": "30", - "credential": { - "accessKey": "minio", - "secretKey": "minio123" - }, - "region": "", - "worm": "off", - "storageclass": { - "standard": "", - "rrs": "" - }, - "cache": { - "drives": [], - "expiry": 90, - "maxuse": 80, - "exclude": [] - }, - "kms": { - "vault": { - "endpoint": "", - "auth": { - "type": "", - "approle": { - "id": "", - "secret": "" - } - }, - "key-id": { - "name": "", - "version": 0 - } - } - }, - "notify": { - "amqp": { - "1": { - "enable": false, - "url": "", - "exchange": "", - "routingKey": "", - "exchangeType": "", - "deliveryMode": 0, - "mandatory": false, - "immediate": false, - "durable": false, - "internal": false, - "noWait": false, - "autoDeleted": false - } - }, - "elasticsearch": { - "1": { - "enable": false, - "format": "", - "url": "", - "index": "" - } - }, - "kafka": { - "1": { - "enable": false, - "brokers": null, - "topic": "", - "tls" : { - "enable" : false, - "skipVerify" : false, - "clientAuth" : 0 - }, - "sasl" : { - "enable" : false, - "username" : "", - "password" : "" - } - } - }, - "mqtt": { - "1": { - "enable": false, - "broker": "", - "topic": "", - "qos": 0, - "clientId": "", - "username": "", - "password": "", - "reconnectInterval": 0, - "keepAliveInterval": 0 - } - }, - "mysql": { - "1": { - "enable": false, - "format": "", - "dsnString": "", - "table": "", - "host": "", - "port": "", - "user": "", - "password": "", - "database": "" - } - }, - "nats": { - "1": { - "enable": false, - "address": "", - "subject": "", - "username": "", - "password": "", - "token": "", - "secure": false, - "pingInterval": 0, - "streaming": { - "enable": false, - "clusterID": "", - "clientID": "", - "async": false, - "maxPubAcksInflight": 0 - } - } - }, - "postgresql": { - "1": { - "enable": false, - "format": "", - "connectionString": "", - "table": "", - "host": "", - "port": "", - "user": "", - "password": "", - "database": "" - } - }, - "redis": { - "1": { - "enable": false, - "format": "", - "address": "", - "password": "", - "key": "" - } - }, - "webhook": { - "1": { - "enable": false, - "endpoint": "" - } - } - }, - "logger": { - "console": { - "enabled": true - }, - "http": { - "target1": { - "enabled": false, - "endpoint": "https://username:password@example.com/api" - } - } - }, - "compress": { - "enabled": false, - "extensions":[".txt",".log",".csv",".json"], - "mime-types":["text/csv","text/plain","application/json"] - } -}`) + "version": "31", + "credential": { + "accessKey": "minio", + "secretKey": "minio123" + }, + "region": "us-east-1", + "worm": "off", + "storageclass": { + "standard": "", + "rrs": "" + }, + "cache": { + "drives": [], + "expiry": 90, + "maxuse": 80, + "exclude": [] + }, + "kms": { + "vault": { + "endpoint": "", + "auth": { + "type": "", + "approle": { + "id": "", + "secret": "" + } + }, + "key-id": { + "name": "", + "version": 0 + } + } + }, + "notify": { + "amqp": { + "1": { + "enable": false, + "url": "", + "exchange": "", + "routingKey": "", + "exchangeType": "", + "deliveryMode": 0, + "mandatory": false, + "immediate": false, + "durable": false, + "internal": false, + "noWait": false, + "autoDeleted": false + } + }, + "elasticsearch": { + "1": { + "enable": false, + "format": "namespace", + "url": "", + "index": "" + } + }, + "kafka": { + "1": { + "enable": false, + "brokers": null, + "topic": "", + "tls": { + "enable": false, + "skipVerify": false, + "clientAuth": 0 + }, + "sasl": { + "enable": false, + "username": "", + "password": "" + } + } + }, + "mqtt": { + "1": { + "enable": false, + "broker": "", + "topic": "", + "qos": 0, + "clientId": "", + "username": "", + "password": "", + "reconnectInterval": 0, + "keepAliveInterval": 0 + } + }, + "mysql": { + "1": { + "enable": false, + "format": "namespace", + "dsnString": "", + "table": "", + "host": "", + "port": "", + "user": "", + "password": "", + "database": "" + } + }, + "nats": { + "1": { + "enable": false, + "address": "", + "subject": "", + "username": "", + "password": "", + "token": "", + "secure": false, + "pingInterval": 0, + "streaming": { + "enable": false, + "clusterID": "", + "clientID": "", + "async": false, + "maxPubAcksInflight": 0 + } + } + }, + "postgresql": { + "1": { + "enable": false, + "format": "namespace", + "connectionString": "", + "table": "", + "host": "", + "port": "", + "user": "", + "password": "", + "database": "" + } + }, + "redis": { + "1": { + "enable": false, + "format": "namespace", + "address": "", + "password": "", + "key": "" + } + }, + "webhook": { + "1": { + "enable": false, + "endpoint": "" + } + } + }, + "logger": { + "console": { + "enabled": true + }, + "http": { + "1": { + "enabled": false, + "endpoint": "https://username:password@example.com/api" + } + } + }, + "compress": { + "enabled": false, + "extensions":[".txt",".log",".csv",".json"], + "mime-types":["text/csv","text/plain","application/json"] + }, + "openid": { + "jwks": { + "url": "" + } + }, + "policy": { + "opa": { + "url": "", + "authToken": "" + } + } +} +`) ) // adminXLTestBed - encapsulates subsystems that need to be setup for @@ -485,6 +497,8 @@ func getServiceCmdRequest(cmd cmdType, cred auth.Credentials, body []byte) (*htt // Set body req.Body = ioutil.NopCloser(bytes.NewReader(body)) + req.ContentLength = int64(len(body)) + // Set sha-sum header req.Header.Set("X-Amz-Content-Sha256", getSHA256Hash(body)) @@ -615,7 +629,7 @@ func TestServiceSetCreds(t *testing.T) { t.Fatalf("JSONify err: %v", err) } - ebody, err := madmin.EncryptServerConfigData(credentials.SecretKey, body) + ebody, err := madmin.EncryptData(credentials.SecretKey, body) if err != nil { t.Fatal(err) } @@ -718,7 +732,7 @@ func TestSetConfigHandler(t *testing.T) { queryVal.Set("config", "") password := globalServerConfig.GetCredential().SecretKey - econfigJSON, err := madmin.EncryptServerConfigData(password, configJSON) + econfigJSON, err := madmin.EncryptData(password, configJSON) if err != nil { t.Fatal(err) } @@ -738,7 +752,7 @@ func TestSetConfigHandler(t *testing.T) { // Check that a very large config file returns an error. { // Make a large enough config string - invalidCfg := []byte(strings.Repeat("A", maxConfigJSONSize+1)) + invalidCfg := []byte(strings.Repeat("A", maxEConfigJSONSize+1)) req, err := buildAdminRequest(queryVal, http.MethodPut, "/config", int64(len(invalidCfg)), bytes.NewReader(invalidCfg)) if err != nil { @@ -768,7 +782,7 @@ func TestSetConfigHandler(t *testing.T) { adminTestBed.router.ServeHTTP(rec, req) respBody := string(rec.Body.Bytes()) if rec.Code != http.StatusBadRequest || - !strings.Contains(respBody, "JSON configuration provided has objects with duplicate keys") { + !strings.Contains(respBody, "JSON configuration provided is of incorrect format") { t.Errorf("Got unexpected response code or body %d - %s", rec.Code, respBody) } } diff --git a/cmd/admin-router.go b/cmd/admin-router.go index a8ad08b9d..cd4f81b6e 100644 --- a/cmd/admin-router.go +++ b/cmd/admin-router.go @@ -68,7 +68,7 @@ func registerAdminRouter(router *mux.Router) { /// Config operations // Update credentials - adminV1Router.Methods(http.MethodPut).Path("/config/credential").HandlerFunc(httpTraceHdrs(adminAPI.UpdateCredentialsHandler)) + adminV1Router.Methods(http.MethodPut).Path("/config/credential").HandlerFunc(httpTraceHdrs(adminAPI.UpdateAdminCredentialsHandler)) // Get config adminV1Router.Methods(http.MethodGet).Path("/config").HandlerFunc(httpTraceHdrs(adminAPI.GetConfigHandler)) // Set config @@ -78,4 +78,13 @@ func registerAdminRouter(router *mux.Router) { adminV1Router.Methods(http.MethodGet).Path("/config-keys").HandlerFunc(httpTraceHdrs(adminAPI.GetConfigKeysHandler)) // Set config keys/values adminV1Router.Methods(http.MethodPut).Path("/config-keys").HandlerFunc(httpTraceHdrs(adminAPI.SetConfigKeysHandler)) + + // Add user IAM + adminV1Router.Methods(http.MethodPut).Path("/add-user").HandlerFunc(httpTraceHdrs(adminAPI.AddUser)).Queries("accessKey", "{accessKey:.*}") + adminV1Router.Methods(http.MethodPut).Path("/add-user-policy").HandlerFunc(httpTraceHdrs(adminAPI.AddUserPolicy)).Queries("accessKey", "{accessKey:.*}") + + // Remove user IAM + adminV1Router.Methods(http.MethodDelete).Path("/remove-user").HandlerFunc(httpTraceHdrs(adminAPI.RemoveUser)).Queries("accessKey", "{accessKey:.*}") + adminV1Router.Methods(http.MethodDelete).Path("/remove-user-policy").HandlerFunc(httpTraceHdrs(adminAPI.RemoveUserPolicy)).Queries("accessKey", "{accessKey:.*}") + } diff --git a/cmd/api-errors.go b/cmd/api-errors.go index 74adf376f..73da05462 100644 --- a/cmd/api-errors.go +++ b/cmd/api-errors.go @@ -192,6 +192,7 @@ const ( ErrAdminConfigNoQuorum ErrAdminConfigTooLarge ErrAdminConfigBadJSON + ErrAdminConfigDuplicateKeys ErrAdminCredentialsMismatch ErrInsecureClientRequest ErrObjectTampered @@ -881,11 +882,16 @@ var errorCodeResponse = map[APIErrorCode]APIError{ ErrAdminConfigTooLarge: { Code: "XMinioAdminConfigTooLarge", Description: fmt.Sprintf("Configuration data provided exceeds the allowed maximum of %d bytes", - maxConfigJSONSize), + maxEConfigJSONSize), HTTPStatusCode: http.StatusBadRequest, }, ErrAdminConfigBadJSON: { Code: "XMinioAdminConfigBadJSON", + Description: "JSON configuration provided is of incorrect format", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrAdminConfigDuplicateKeys: { + Code: "XMinioAdminConfigDuplicateKeys", Description: "JSON configuration provided has objects with duplicate keys", HTTPStatusCode: http.StatusBadRequest, }, diff --git a/cmd/auth-handler.go b/cmd/auth-handler.go index c4bd19d46..e34e0b896 100644 --- a/cmd/auth-handler.go +++ b/cmd/auth-handler.go @@ -27,8 +27,10 @@ import ( "net/http" "strings" + jwtgo "github.com/dgrijalva/jwt-go" "github.com/minio/minio/cmd/logger" "github.com/minio/minio/pkg/hash" + "github.com/minio/minio/pkg/iam/policy" "github.com/minio/minio/pkg/policy" ) @@ -86,6 +88,7 @@ const ( authTypeSigned authTypeSignedV2 authTypeJWT + authTypeSTS ) // Get request authentication type. @@ -106,6 +109,8 @@ func getRequestAuthType(r *http.Request) authType { return authTypePostPolicy } else if _, ok := r.Header["Authorization"]; !ok { return authTypeAnonymous + } else if _, ok := r.URL.Query()["Action"]; ok { + return authTypeSTS } return authTypeUnknown } @@ -114,7 +119,21 @@ func getRequestAuthType(r *http.Request) authType { // It does not accept presigned or JWT or anonymous requests. func checkAdminRequestAuthType(r *http.Request, region string) APIErrorCode { s3Err := ErrAccessDenied - if _, ok := r.Header["X-Amz-Content-Sha256"]; ok && getRequestAuthType(r) == authTypeSigned && !skipContentSha256Cksum(r) { // we only support V4 (no presign) with auth. body + if _, ok := r.Header["X-Amz-Content-Sha256"]; ok && + getRequestAuthType(r) == authTypeSigned && !skipContentSha256Cksum(r) { + // We only support admin credentials to access admin APIs. + + var owner bool + _, owner, s3Err = getReqAccessKeyV4(r, region) + if s3Err != ErrNone { + return s3Err + } + + if !owner { + return ErrAccessDenied + } + + // we only support V4 (no presign) with auth body s3Err = isReqAuthenticated(r, region) } if s3Err != ErrNone { @@ -125,30 +144,62 @@ func checkAdminRequestAuthType(r *http.Request, region string) APIErrorCode { return s3Err } -func checkRequestAuthType(ctx context.Context, r *http.Request, action policy.Action, bucketName, objectName string) APIErrorCode { - isOwner := true - accountName := globalServerConfig.GetCredential().AccessKey +// Fetch the security token set by the client. +func getSessionToken(r *http.Request) (token string) { + token = r.Header.Get("X-Amz-Security-Token") + if token != "" { + return token + } + return r.URL.Query().Get("X-Amz-Security-Token") +} + +// Fetch claims in the security token returned by the client and validate the token. +func getClaimsFromToken(r *http.Request) (map[string]interface{}, APIErrorCode) { + claims := make(map[string]interface{}) + token := getSessionToken(r) + if token == "" { + return nil, ErrNone + } + p := &jwtgo.Parser{} + jtoken, err := p.ParseWithClaims(token, jwtgo.MapClaims(claims), stsTokenCallback) + if err != nil { + return nil, toAPIErrorCode(errAuthentication) + } + if !jtoken.Valid { + return nil, toAPIErrorCode(errAuthentication) + } + return claims, ErrNone +} +// Check request auth type verifies the incoming http request +// - validates the request signature +// - validates the policy action if anonymous tests bucket policies if any, +// for authenticated requests validates IAM policies. +// returns APIErrorCode if any to be replied to the client. +func checkRequestAuthType(ctx context.Context, r *http.Request, action policy.Action, bucketName, objectName string) (s3Err APIErrorCode) { + var accessKey string + var owner bool switch getRequestAuthType(r) { - case authTypeUnknown: + case authTypeUnknown, authTypeStreamingSigned: return ErrAccessDenied case authTypePresignedV2, authTypeSignedV2: - if errorCode := isReqAuthenticatedV2(r); errorCode != ErrNone { - return errorCode + if s3Err = isReqAuthenticatedV2(r); s3Err != ErrNone { + return s3Err } + accessKey, owner, s3Err = getReqAccessKeyV2(r) case authTypeSigned, authTypePresigned: region := globalServerConfig.GetRegion() switch action { case policy.GetBucketLocationAction, policy.ListAllMyBucketsAction: region = "" } - - if errorCode := isReqAuthenticated(r, region); errorCode != ErrNone { - return errorCode + if s3Err = isReqAuthenticated(r, region); s3Err != ErrNone { + return s3Err } - default: - isOwner = false - accountName = "" + accessKey, owner, s3Err = getReqAccessKeyV4(r, region) + } + if s3Err != ErrNone { + return s3Err } // LocationConstraint is valid only for CreateBucketAction. @@ -174,17 +225,36 @@ func checkRequestAuthType(ctx context.Context, r *http.Request, action policy.Ac r.Body = ioutil.NopCloser(bytes.NewReader(payload)) } - if globalPolicySys.IsAllowed(policy.Args{ - AccountName: accountName, - Action: action, + claims, s3Err := getClaimsFromToken(r) + if s3Err != ErrNone { + return s3Err + } + + if accessKey == "" { + if globalPolicySys.IsAllowed(policy.Args{ + AccountName: accessKey, + Action: action, + BucketName: bucketName, + ConditionValues: getConditionValues(r, locationConstraint), + IsOwner: false, + ObjectName: objectName, + }) { + return ErrNone + } + return ErrAccessDenied + } + + if globalIAMSys.IsAllowed(iampolicy.Args{ + AccountName: accessKey, + Action: iampolicy.Action(action), BucketName: bucketName, - ConditionValues: getConditionValues(r, locationConstraint), - IsOwner: isOwner, + ConditionValues: getConditionValues(r, ""), ObjectName: objectName, + IsOwner: owner, + Claims: claims, }) { return ErrNone } - return ErrAccessDenied } @@ -297,3 +367,55 @@ func (a authHandler) ServeHTTP(w http.ResponseWriter, r *http.Request) { } writeErrorResponse(w, ErrSignatureVersionNotSupported, r.URL) } + +// isPutAllowed - check if PUT operation is allowed on the resource, this +// call verifies bucket policies and IAM policies, supports multi user +// checks etc. +func isPutAllowed(atype authType, bucketName, objectName string, r *http.Request) (s3Err APIErrorCode) { + var accessKey string + var owner bool + switch atype { + case authTypeUnknown: + return ErrAccessDenied + case authTypeSignedV2, authTypePresignedV2: + accessKey, owner, s3Err = getReqAccessKeyV2(r) + case authTypeStreamingSigned, authTypePresigned, authTypeSigned: + region := globalServerConfig.GetRegion() + accessKey, owner, s3Err = getReqAccessKeyV4(r, region) + } + if s3Err != ErrNone { + return s3Err + } + + claims, s3Err := getClaimsFromToken(r) + if s3Err != ErrNone { + return s3Err + } + + if accessKey == "" { + if globalPolicySys.IsAllowed(policy.Args{ + AccountName: accessKey, + Action: policy.PutObjectAction, + BucketName: bucketName, + ConditionValues: getConditionValues(r, ""), + IsOwner: false, + ObjectName: objectName, + }) { + return ErrNone + } + return ErrAccessDenied + } + + if globalIAMSys.IsAllowed(iampolicy.Args{ + AccountName: accessKey, + Action: policy.PutObjectAction, + BucketName: bucketName, + ConditionValues: getConditionValues(r, ""), + ObjectName: objectName, + IsOwner: owner, + Claims: claims, + }) { + return ErrNone + } + return ErrAccessDenied +} diff --git a/cmd/bucket-notification-handlers.go b/cmd/bucket-notification-handlers.go index 792d7dbc5..7f176f597 100644 --- a/cmd/bucket-notification-handlers.go +++ b/cmd/bucket-notification-handlers.go @@ -139,7 +139,7 @@ func (api objectAPIHandlers) PutBucketNotificationHandler(w http.ResponseWriter, return } - if err = saveNotificationConfig(objectAPI, bucketName, config); err != nil { + if err = saveNotificationConfig(ctx, objectAPI, bucketName, config); err != nil { writeErrorResponse(w, toAPIErrorCode(err), r.URL) return } diff --git a/cmd/common-main.go b/cmd/common-main.go index 4c80985c1..91d558453 100644 --- a/cmd/common-main.go +++ b/cmd/common-main.go @@ -28,12 +28,11 @@ import ( etcd "github.com/coreos/etcd/clientv3" dns2 "github.com/miekg/dns" "github.com/minio/cli" + "github.com/minio/minio-go/pkg/set" "github.com/minio/minio/cmd/crypto" "github.com/minio/minio/cmd/logger" "github.com/minio/minio/pkg/auth" "github.com/minio/minio/pkg/dns" - - "github.com/minio/minio-go/pkg/set" ) // Check for updates and print a notification message @@ -122,6 +121,7 @@ func handleCommonEnvVars() { if err != nil { logger.Fatal(uiErrInvalidCredentials(err), "Unable to validate credentials inherited from the shell environment") } + cred.Expiration = timeSentinel // credential Envs are set globally. globalIsEnvCreds = true @@ -131,7 +131,7 @@ func handleCommonEnvVars() { if browser := os.Getenv("MINIO_BROWSER"); browser != "" { browserFlag, err := ParseBoolFlag(browser) if err != nil { - logger.Fatal(uiErrInvalidBrowserValue(nil).Msg("Unknown value `%s`", browser), "Invalid MINIO_BROWSER environment variable") + logger.Fatal(uiErrInvalidBrowserValue(nil).Msg("Unknown value `%s`", browser), "Invalid MINIO_BROWSER value in environment variable") } // browser Envs are set globally, this does not represent @@ -162,7 +162,7 @@ func handleCommonEnvVars() { globalDomainName, globalIsEnvDomainName = os.LookupEnv("MINIO_DOMAIN") if globalDomainName != "" { if _, ok = dns2.IsDomainName(globalDomainName); !ok { - logger.Fatal(uiErrInvalidDomainValue(nil).Msg("Unknown value `%s`", globalDomainName), "Invalid MINIO_DOMAIN environment variable") + logger.Fatal(uiErrInvalidDomainValue(nil).Msg("Unknown value `%s`", globalDomainName), "Invalid MINIO_DOMAIN value in environment variable") } } @@ -257,7 +257,7 @@ func handleCommonEnvVars() { if worm := os.Getenv("MINIO_WORM"); worm != "" { wormFlag, err := ParseBoolFlag(worm) if err != nil { - logger.Fatal(uiErrInvalidWormValue(nil).Msg("Unknown value `%s`", worm), "Unable to validate MINIO_WORM environment variable") + logger.Fatal(uiErrInvalidWormValue(nil).Msg("Unknown value `%s`", worm), "Invalid MINIO_WORM value in environment variable") } // worm Envs are set globally, this does not represent diff --git a/cmd/config-common.go b/cmd/config-common.go new file mode 100644 index 000000000..5b1fc8e62 --- /dev/null +++ b/cmd/config-common.go @@ -0,0 +1,119 @@ +/* + * Minio Cloud Storage, (C) 2018 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 ( + "bytes" + "context" + "errors" + + etcd "github.com/coreos/etcd/clientv3" + "github.com/minio/minio/cmd/logger" + "github.com/minio/minio/pkg/hash" +) + +var errConfigNotFound = errors.New("config file not found") + +func readConfig(ctx context.Context, objAPI ObjectLayer, configFile string) ([]byte, error) { + var buffer bytes.Buffer + // Read entire content by setting size to -1 + if err := objAPI.GetObject(ctx, minioMetaBucket, configFile, 0, -1, &buffer, "", ObjectOptions{}); err != nil { + // Treat object not found as config not found. + if isErrObjectNotFound(err) { + return nil, errConfigNotFound + } + + logger.GetReqInfo(ctx).AppendTags("configFile", configFile) + logger.LogIf(ctx, err) + return nil, err + } + + // Return config not found on empty content. + if buffer.Len() == 0 { + return nil, errConfigNotFound + } + + return buffer.Bytes(), nil +} + +func saveConfig(ctx context.Context, objAPI ObjectLayer, configFile string, data []byte) error { + hashReader, err := hash.NewReader(bytes.NewReader(data), int64(len(data)), "", getSHA256Hash(data), int64(len(data))) + if err != nil { + return err + } + + _, err = objAPI.PutObject(ctx, minioMetaBucket, configFile, hashReader, nil, ObjectOptions{}) + return err +} + +func readConfigEtcd(ctx context.Context, client *etcd.Client, configFile string) ([]byte, error) { + resp, err := client.Get(ctx, configFile) + if err != nil { + return nil, err + } + if resp.Count == 0 { + return nil, errConfigNotFound + } + for _, ev := range resp.Kvs { + if string(ev.Key) == configFile { + return ev.Value, nil + } + } + return nil, errConfigNotFound +} + +// watchConfig - watches for changes on `configFile` on etcd and loads them. +func watchConfig(objAPI ObjectLayer, configFile string, loadCfgFn func(ObjectLayer) error) { + if globalEtcdClient != nil { + for watchResp := range globalEtcdClient.Watch(context.Background(), configFile) { + for _, event := range watchResp.Events { + if event.IsModify() || event.IsCreate() { + loadCfgFn(objAPI) + } + } + } + } +} + +func checkConfigEtcd(ctx context.Context, client *etcd.Client, configFile string) error { + resp, err := globalEtcdClient.Get(ctx, configFile) + if err != nil { + return err + } + if resp.Count == 0 { + return errConfigNotFound + } + return nil +} + +func checkConfig(ctx context.Context, objAPI ObjectLayer, configFile string) error { + if globalEtcdClient != nil { + return checkConfigEtcd(ctx, globalEtcdClient, configFile) + } + + if _, err := objAPI.GetObjectInfo(ctx, minioMetaBucket, configFile, ObjectOptions{}); err != nil { + // Treat object not found as config not found. + if isErrObjectNotFound(err) { + return errConfigNotFound + } + + logger.GetReqInfo(ctx).AppendTags("configFile", configFile) + logger.LogIf(ctx, err) + return err + } + return nil +} diff --git a/cmd/config-current.go b/cmd/config-current.go index 9e7949cd8..2eb0dbc3c 100644 --- a/cmd/config-current.go +++ b/cmd/config-current.go @@ -25,10 +25,11 @@ import ( "github.com/minio/minio/cmd/crypto" "github.com/minio/minio/cmd/logger" - "github.com/minio/minio/pkg/auth" "github.com/minio/minio/pkg/event" "github.com/minio/minio/pkg/event/target" + "github.com/minio/minio/pkg/iam/policy" + "github.com/minio/minio/pkg/iam/validator" ) // Steps to move from version N to version N+1 @@ -40,9 +41,9 @@ import ( // 6. Make changes in config-current_test.go for any test change // Config version -const serverConfigVersion = "30" +const serverConfigVersion = "31" -type serverConfig = serverConfigV30 +type serverConfig = serverConfigV31 var ( // globalServerConfig server config. @@ -173,55 +174,55 @@ func (s *serverConfig) Validate() error { // Worm, Cache and StorageClass values are already validated during json unmarshal for _, v := range s.Notify.AMQP { if err := v.Validate(); err != nil { - return fmt.Errorf("amqp: %s", err.Error()) + return fmt.Errorf("amqp: %s", err) } } for _, v := range s.Notify.Elasticsearch { if err := v.Validate(); err != nil { - return fmt.Errorf("elasticsearch: %s", err.Error()) + return fmt.Errorf("elasticsearch: %s", err) } } for _, v := range s.Notify.Kafka { if err := v.Validate(); err != nil { - return fmt.Errorf("kafka: %s", err.Error()) + return fmt.Errorf("kafka: %s", err) } } for _, v := range s.Notify.MQTT { if err := v.Validate(); err != nil { - return fmt.Errorf("mqtt: %s", err.Error()) + return fmt.Errorf("mqtt: %s", err) } } for _, v := range s.Notify.MySQL { if err := v.Validate(); err != nil { - return fmt.Errorf("mysql: %s", err.Error()) + return fmt.Errorf("mysql: %s", err) } } for _, v := range s.Notify.NATS { if err := v.Validate(); err != nil { - return fmt.Errorf("nats: %s", err.Error()) + return fmt.Errorf("nats: %s", err) } } for _, v := range s.Notify.PostgreSQL { if err := v.Validate(); err != nil { - return fmt.Errorf("postgreSQL: %s", err.Error()) + return fmt.Errorf("postgreSQL: %s", err) } } for _, v := range s.Notify.Redis { if err := v.Validate(); err != nil { - return fmt.Errorf("redis: %s", err.Error()) + return fmt.Errorf("redis: %s", err) } } for _, v := range s.Notify.Webhook { if err := v.Validate(); err != nil { - return fmt.Errorf("webhook: %s", err.Error()) + return fmt.Errorf("webhook: %s", err) } } @@ -503,17 +504,31 @@ func (s *serverConfig) loadToCachedConfigs() { globalKMSKeyID = globalKMSConfig.Vault.Key.Name } } + if !globalIsCompressionEnabled { compressionConf := s.GetCompressionConfig() globalCompressExtensions = compressionConf.Extensions globalCompressMimeTypes = compressionConf.MimeTypes globalIsCompressionEnabled = compressionConf.Enabled } + + if globalIAMValidators == nil { + globalIAMValidators = getAuthValidators(s) + } + + if globalPolicyOPA == nil { + if s.Policy.OPA.URL != nil && s.Policy.OPA.URL.String() != "" { + globalPolicyOPA = iampolicy.NewOpa(iampolicy.OpaArgs{ + URL: s.Policy.OPA.URL, + AuthToken: s.Policy.OPA.AuthToken, + }) + } + } } -// newConfig - initialize a new server config, saves env parameters if +// newSrvConfig - initialize a new server config, saves env parameters if // found, otherwise use default parameters -func newConfig(objAPI ObjectLayer) error { +func newSrvConfig(objAPI ObjectLayer) error { // Initialize server config. srvCfg := newServerConfig() @@ -564,6 +579,20 @@ func loadConfig(objAPI ObjectLayer) error { return nil } +// getAuthValidators - returns ValidatorList which contains +// enabled providers in server config. +// A new authentication provider is added like below +// * Add a new provider in pkg/iam/validator package. +func getAuthValidators(config *serverConfig) *validator.Validators { + validators := validator.NewValidators() + + if config.OpenID.JWKS.URL != nil { + validators.Add(validator.NewJWT(config.OpenID.JWKS)) + } + + return validators +} + // getNotificationTargets - returns TargetList which contains enabled targets in serverConfig. // A new notification target is added like below // * Add a new target in pkg/event/target package. diff --git a/cmd/config-current_test.go b/cmd/config-current_test.go index 047203702..cac984e03 100644 --- a/cmd/config-current_test.go +++ b/cmd/config-current_test.go @@ -237,7 +237,7 @@ func TestValidateConfig(t *testing.T) { } for i, testCase := range testCases { - if err = saveConfig(objLayer, configPath, []byte(testCase.configData)); err != nil { + if err = saveConfig(context.Background(), objLayer, configPath, []byte(testCase.configData)); err != nil { t.Fatal(err) } _, err = getValidConfig(objLayer) @@ -260,8 +260,16 @@ func TestConfigDiff(t *testing.T) { {&serverConfig{}, nil, "Given configuration is empty"}, // 2 { - &serverConfig{Credential: auth.Credentials{"u1", "p1"}}, - &serverConfig{Credential: auth.Credentials{"u1", "p2"}}, + &serverConfig{Credential: auth.Credentials{ + AccessKey: "u1", + SecretKey: "p1", + Expiration: timeSentinel, + }}, + &serverConfig{Credential: auth.Credentials{ + AccessKey: "u1", + SecretKey: "p2", + Expiration: timeSentinel, + }}, "Credential configuration differs", }, // 3 diff --git a/cmd/config-migrate.go b/cmd/config-migrate.go index 278534961..b2e18537d 100644 --- a/cmd/config-migrate.go +++ b/cmd/config-migrate.go @@ -18,6 +18,7 @@ package cmd import ( "context" + "encoding/json" "fmt" "os" "path" @@ -29,6 +30,8 @@ import ( "github.com/minio/minio/pkg/dns" "github.com/minio/minio/pkg/event" "github.com/minio/minio/pkg/event/target" + "github.com/minio/minio/pkg/iam/policy" + "github.com/minio/minio/pkg/iam/validator" xnet "github.com/minio/minio/pkg/net" "github.com/minio/minio/pkg/quick" ) @@ -2410,7 +2413,7 @@ func migrateV27ToV28() error { return nil } -// Migrates '.minio.sys/config.json' to v30. +// Migrates '.minio.sys/config.json' to v31. func migrateMinioSysConfig(objAPI ObjectLayer) error { if err := migrateV27ToV28MinioSys(objAPI); err != nil { return err @@ -2418,73 +2421,167 @@ func migrateMinioSysConfig(objAPI ObjectLayer) error { if err := migrateV28ToV29MinioSys(objAPI); err != nil { return err } - return migrateV29ToV30MinioSys(objAPI) + if err := migrateV29ToV30MinioSys(objAPI); err != nil { + return err + } + return migrateV30ToV31MinioSys(objAPI) } -func migrateV29ToV30MinioSys(objAPI ObjectLayer) error { +func checkConfigVersion(objAPI ObjectLayer, configFile string, version string) (bool, []byte, error) { + data, err := readConfig(context.Background(), objAPI, configFile) + if err != nil { + return false, nil, err + } + + var versionConfig struct { + Version string `json:"version"` + } + + vcfg := &versionConfig + if err = json.Unmarshal(data, vcfg); err != nil { + return false, nil, err + } + return vcfg.Version == version, data, nil +} + +func migrateV27ToV28MinioSys(objAPI ObjectLayer) error { configFile := path.Join(minioConfigPrefix, minioConfigFile) - srvConfig, err := readServerConfig(context.Background(), objAPI) + ok, data, err := checkConfigVersion(objAPI, configFile, "27") if err == errConfigNotFound { return nil } else if err != nil { return fmt.Errorf("Unable to load config file. %v", err) } - if srvConfig.Version != "29" { + if !ok { return nil } - srvConfig.Version = "30" - // Init compression config.For future migration, Compression config needs to be copied over from previous version. - srvConfig.Compression.Enabled = false - srvConfig.Compression.Extensions = globalCompressExtensions - srvConfig.Compression.MimeTypes = globalCompressMimeTypes - if err = saveServerConfig(context.Background(), objAPI, srvConfig); err != nil { - return fmt.Errorf("Failed to migrate config from 29 to 30 . %v", err) + cfg := &serverConfigV28{} + if err = json.Unmarshal(data, cfg); err != nil { + return err } - logger.Info(configMigrateMSGTemplate, configFile, "29", "30") + cfg.Version = "28" + cfg.KMS = crypto.KMSConfig{} + + data, err = json.Marshal(cfg) + if err != nil { + return err + } + + if err = saveConfig(context.Background(), objAPI, configFile, data); err != nil { + return fmt.Errorf("Failed to migrate config from ‘27’ to ‘28’. %v", err) + } + + logger.Info(configMigrateMSGTemplate, configFile, "27", "28") return nil } func migrateV28ToV29MinioSys(objAPI ObjectLayer) error { configFile := path.Join(minioConfigPrefix, minioConfigFile) - srvConfig, err := readServerConfig(context.Background(), objAPI) + + ok, data, err := checkConfigVersion(objAPI, configFile, "28") if err == errConfigNotFound { return nil } else if err != nil { return fmt.Errorf("Unable to load config file. %v", err) } - if srvConfig.Version != "28" { + if !ok { return nil } - srvConfig.Version = "29" - if err = saveServerConfig(context.Background(), objAPI, srvConfig); err != nil { - return fmt.Errorf("Failed to migrate config from ‘28’ to ‘29’. %v", err) + cfg := &serverConfigV29{} + if err = json.Unmarshal(data, cfg); err != nil { + return err + } + + cfg.Version = "29" + data, err = json.Marshal(cfg) + if err != nil { + return err + } + + if err = saveConfig(context.Background(), objAPI, configFile, data); err != nil { + return fmt.Errorf("Failed to migrate config from ‘28’ to ‘29’. %v", err) } logger.Info(configMigrateMSGTemplate, configFile, "28", "29") return nil } -func migrateV27ToV28MinioSys(objAPI ObjectLayer) error { +func migrateV29ToV30MinioSys(objAPI ObjectLayer) error { configFile := path.Join(minioConfigPrefix, minioConfigFile) - srvConfig, err := readServerConfig(context.Background(), objAPI) + + ok, data, err := checkConfigVersion(objAPI, configFile, "29") if err == errConfigNotFound { return nil } else if err != nil { return fmt.Errorf("Unable to load config file. %v", err) } - if srvConfig.Version != "27" { + if !ok { return nil } - srvConfig.Version = "28" - srvConfig.KMS = crypto.KMSConfig{} - if err = saveServerConfig(context.Background(), objAPI, srvConfig); err != nil { - return fmt.Errorf("Failed to migrate config from ‘27’ to ‘28’. %v", err) + cfg := &serverConfigV30{} + if err = json.Unmarshal(data, cfg); err != nil { + return err } - logger.Info(configMigrateMSGTemplate, configFile, "27", "28") + cfg.Version = "30" + // Init compression config.For future migration, Compression config needs to be copied over from previous version. + cfg.Compression.Enabled = false + cfg.Compression.Extensions = globalCompressExtensions + cfg.Compression.MimeTypes = globalCompressMimeTypes + + data, err = json.Marshal(cfg) + if err != nil { + return err + } + + if err = saveConfig(context.Background(), objAPI, configFile, data); err != nil { + return fmt.Errorf("Failed to migrate config from ‘29’ to ‘30’. %v", err) + } + + logger.Info(configMigrateMSGTemplate, configFile, "29", "30") + return nil +} + +func migrateV30ToV31MinioSys(objAPI ObjectLayer) error { + configFile := path.Join(minioConfigPrefix, minioConfigFile) + + ok, data, err := checkConfigVersion(objAPI, configFile, "30") + if err == errConfigNotFound { + return nil + } else if err != nil { + return fmt.Errorf("Unable to load config file. %v", err) + } + if !ok { + return nil + } + + cfg := &serverConfigV31{} + if err = json.Unmarshal(data, cfg); err != nil { + return err + } + + cfg.Version = "31" + cfg.OpenID.JWKS = validator.JWKSArgs{ + URL: &xnet.URL{}, + } + cfg.Policy.OPA = iampolicy.OpaArgs{ + URL: &xnet.URL{}, + AuthToken: "", + } + + data, err = json.Marshal(cfg) + if err != nil { + return err + } + + if err = saveConfig(context.Background(), objAPI, configFile, data); err != nil { + return fmt.Errorf("Failed to migrate config from ‘30’ to ‘31’. %v", err) + } + + logger.Info(configMigrateMSGTemplate, configFile, "30", "31") return nil } diff --git a/cmd/config-migrate_test.go b/cmd/config-migrate_test.go index 8fe5c8021..98723e0b7 100644 --- a/cmd/config-migrate_test.go +++ b/cmd/config-migrate_test.go @@ -159,8 +159,8 @@ func TestServerConfigMigrateInexistentConfig(t *testing.T) { } } -// Test if a config migration from v2 to v29 is successfully done -func TestServerConfigMigrateV2toV29(t *testing.T) { +// Test if a config migration from v2 to v30 is successfully done +func TestServerConfigMigrateV2toV30(t *testing.T) { rootPath, err := ioutil.TempDir(globalTestTmpDir, "minio-") if err != nil { t.Fatal(err) @@ -222,6 +222,7 @@ func TestServerConfigMigrateV2toV29(t *testing.T) { if globalServerConfig.Credential.AccessKey != accessKey { t.Fatalf("Access key lost during migration, expected: %v, found:%v", accessKey, globalServerConfig.Credential.AccessKey) } + if globalServerConfig.Credential.SecretKey != secretKey { t.Fatalf("Secret key lost during migration, expected: %v, found: %v", secretKey, globalServerConfig.Credential.SecretKey) } diff --git a/cmd/config-versions.go b/cmd/config-versions.go index ba8baa28d..9ef6a9e5e 100644 --- a/cmd/config-versions.go +++ b/cmd/config-versions.go @@ -22,6 +22,8 @@ import ( "github.com/minio/minio/cmd/crypto" "github.com/minio/minio/pkg/auth" "github.com/minio/minio/pkg/event/target" + "github.com/minio/minio/pkg/iam/policy" + "github.com/minio/minio/pkg/iam/validator" "github.com/minio/minio/pkg/quick" ) @@ -755,6 +757,9 @@ type serverConfigV28 struct { Logger loggerConfig `json:"logger"` } +// serverConfigV29 is just like version '28'. +type serverConfigV29 serverConfigV28 + // compressionConfig represents the compression settings. type compressionConfig struct { Enabled bool `json:"enabled"` @@ -765,8 +770,34 @@ type compressionConfig struct { // serverConfigV30 is just like version '29', stores additionally // extensions and mimetypes fields for compression. type serverConfigV30 struct { - quick.Config `json:"-"` // ignore interfaces + Version string `json:"version"` + + // S3 API configuration. + Credential auth.Credentials `json:"credential"` + Region string `json:"region"` + Worm BoolFlag `json:"worm"` + + // Storage class configuration + StorageClass storageClassConfig `json:"storageclass"` + + // Cache configuration + Cache CacheConfig `json:"cache"` + + // KMS configuration + KMS crypto.KMSConfig `json:"kms"` + + // Notification queue configuration. + Notify notifier `json:"notify"` + + // Logger configuration + Logger loggerConfig `json:"logger"` + // Compression configuration + Compression compressionConfig `json:"compress"` +} + +// serverConfigV31 is just like version '30', with OPA and OpenID configuration. +type serverConfigV31 struct { Version string `json:"version"` // S3 API configuration. @@ -791,4 +822,18 @@ type serverConfigV30 struct { // Compression configuration Compression compressionConfig `json:"compress"` + + // OpenID configuration + OpenID struct { + // JWKS validator config. + JWKS validator.JWKSArgs `json:"jwks"` + } `json:"openid"` + + // External policy enforcements. + Policy struct { + // OPA configuration. + OPA iampolicy.OpaArgs `json:"opa"` + + // Add new external policy enforcements here. + } `json:"policy"` } diff --git a/cmd/config.go b/cmd/config.go index 66e422b2f..0319a3933 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -20,9 +20,6 @@ import ( "bytes" "context" "encoding/json" - "errors" - "io" - "io/ioutil" "os" "path" "runtime" @@ -30,7 +27,6 @@ import ( "time" "github.com/minio/minio/cmd/logger" - "github.com/minio/minio/pkg/hash" "github.com/minio/minio/pkg/quick" ) @@ -63,16 +59,10 @@ func saveServerConfig(ctx context.Context, objAPI ObjectLayer, config *serverCon } // Create a backup of the current config - reader, err := readConfig(ctx, objAPI, configFile) + oldData, err := readConfig(ctx, objAPI, configFile) if err == nil { - var oldData []byte - oldData, err = ioutil.ReadAll(reader) - if err != nil { - return err - } backupConfigFile := path.Join(minioConfigPrefix, minioConfigBackupFile) - err = saveConfig(objAPI, backupConfigFile, oldData) - if err != nil { + if err = saveConfig(ctx, objAPI, backupConfigFile, oldData); err != nil { return err } } else { @@ -82,40 +72,18 @@ func saveServerConfig(ctx context.Context, objAPI ObjectLayer, config *serverCon } // Save the new config in the std config path - return saveConfig(objAPI, configFile, data) -} - -func readConfigEtcd(configFile string) ([]byte, error) { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - resp, err := globalEtcdClient.Get(ctx, configFile) - defer cancel() - if err != nil { - return nil, err - } - if resp.Count == 0 { - return nil, errConfigNotFound - } - for _, ev := range resp.Kvs { - if string(ev.Key) == configFile { - return ev.Value, nil - } - } - return nil, errConfigNotFound + return saveConfig(ctx, objAPI, configFile, data) } func readServerConfig(ctx context.Context, objAPI ObjectLayer) (*serverConfig, error) { var configData []byte var err error + configFile := path.Join(minioConfigPrefix, minioConfigFile) if globalEtcdClient != nil { - configData, err = readConfigEtcd(configFile) + configData, err = readConfigEtcd(ctx, globalEtcdClient, configFile) } else { - var reader io.Reader - reader, err = readConfig(ctx, objAPI, configFile) - if err != nil { - return nil, err - } - configData, err = ioutil.ReadAll(reader) + configData, err = readConfig(ctx, objAPI, configFile) } if err != nil { return nil, err @@ -130,82 +98,17 @@ func readServerConfig(ctx context.Context, objAPI ObjectLayer) (*serverConfig, e } var config = &serverConfig{} - if err := json.Unmarshal(configData, config); err != nil { + if err = json.Unmarshal(configData, config); err != nil { return nil, err } - if err := quick.CheckData(config); err != nil { + if err = quick.CheckData(config); err != nil { return nil, err } return config, nil } -func checkServerConfigEtcd(configFile string) error { - ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute) - resp, err := globalEtcdClient.Get(ctx, configFile) - defer cancel() - if err != nil { - return err - } - if resp.Count == 0 { - return errConfigNotFound - } - return nil -} - -func checkServerConfig(ctx context.Context, objAPI ObjectLayer) error { - configFile := path.Join(minioConfigPrefix, minioConfigFile) - if globalEtcdClient != nil { - return checkServerConfigEtcd(configFile) - } - - if _, err := objAPI.GetObjectInfo(ctx, minioMetaBucket, configFile, ObjectOptions{}); err != nil { - // Convert ObjectNotFound to errConfigNotFound - if isErrObjectNotFound(err) { - return errConfigNotFound - } - logger.GetReqInfo(ctx).AppendTags("configFile", configFile) - logger.LogIf(ctx, err) - return err - } - return nil -} - -func saveConfig(objAPI ObjectLayer, configFile string, data []byte) error { - hashReader, err := hash.NewReader(bytes.NewReader(data), int64(len(data)), "", getSHA256Hash(data), int64(len(data))) - if err != nil { - return err - } - - _, err = objAPI.PutObject(context.Background(), minioMetaBucket, configFile, hashReader, nil, ObjectOptions{}) - return err -} - -var errConfigNotFound = errors.New("config file not found") - -func readConfig(ctx context.Context, objAPI ObjectLayer, configFile string) (*bytes.Buffer, error) { - var buffer bytes.Buffer - // Read entire content by setting size to -1 - if err := objAPI.GetObject(ctx, minioMetaBucket, configFile, 0, -1, &buffer, "", ObjectOptions{}); err != nil { - // Convert ObjectNotFound and IncompleteBody errors into errConfigNotFound - if isErrObjectNotFound(err) || isErrIncompleteBody(err) { - return nil, errConfigNotFound - } - - logger.GetReqInfo(ctx).AppendTags("configFile", configFile) - logger.LogIf(ctx, err) - return nil, err - } - - // Return config not found on empty content. - if buffer.Len() == 0 { - return nil, errConfigNotFound - } - - return &buffer, nil -} - // ConfigSys - config system. type ConfigSys struct{} @@ -257,8 +160,9 @@ func NewConfigSys() *ConfigSys { func migrateConfigToMinioSys(objAPI ObjectLayer) error { defer os.Rename(getConfigFile(), getConfigFile()+".deprecated") + configFile := path.Join(minioConfigPrefix, minioConfigFile) // Verify if backend already has the file. - if err := checkServerConfig(context.Background(), objAPI); err != errConfigNotFound { + if err := checkConfig(context.Background(), objAPI, configFile); err != errConfigNotFound { return err } // if errConfigNotFound proceed to migrate.. @@ -287,7 +191,14 @@ func initConfig(objAPI ObjectLayer) error { resp, err := globalEtcdClient.Get(ctx, getConfigFile()) cancel() if err == nil && resp.Count > 0 { - return migrateConfig() + if err = migrateConfig(); err != nil { + return err + } + + // Migrates etcd ${HOME}/.minio/config.json to '/config/config.json' + if err := migrateConfigToMinioSys(objAPI); err != nil { + return err + } } } else { if isFile(getConfigFile()) { @@ -303,10 +214,15 @@ func initConfig(objAPI ObjectLayer) error { } } - if err := checkServerConfig(context.Background(), objAPI); err != nil { + configFile := path.Join(minioConfigPrefix, minioConfigFile) + + // Watch config for changes and reloads them in-memory. + go watchConfig(objAPI, configFile, loadConfig) + + if err := checkConfig(context.Background(), objAPI, configFile); err != nil { if err == errConfigNotFound { // Config file does not exist, we create it fresh and return upon success. - if err = newConfig(objAPI); err != nil { + if err = newSrvConfig(objAPI); err != nil { return err } } else { diff --git a/cmd/fs-v1.go b/cmd/fs-v1.go index 86ddd3669..98ec8721a 100644 --- a/cmd/fs-v1.go +++ b/cmd/fs-v1.go @@ -1018,7 +1018,7 @@ func (fs *FSObjects) listDirFactory(isLeaf isLeafFunc) listDirFunc { listDir := func(bucket, prefixDir, prefixEntry string) (entries []string, delayIsLeaf bool) { var err error entries, err = readDir(pathJoin(fs.fsPath, bucket, prefixDir)) - if err != nil { + if err != nil && err != errFileNotFound { logger.LogIf(context.Background(), err) return } @@ -1274,7 +1274,7 @@ func (fs *FSObjects) ListBucketsHeal(ctx context.Context) ([]BucketInfo, error) // SetBucketPolicy sets policy on bucket func (fs *FSObjects) SetBucketPolicy(ctx context.Context, bucket string, policy *policy.Policy) error { - return savePolicyConfig(fs, bucket, policy) + return savePolicyConfig(ctx, fs, bucket, policy) } // GetBucketPolicy will get policy on bucket diff --git a/cmd/gateway-main.go b/cmd/gateway-main.go index aedfe6d34..34cdc71d2 100644 --- a/cmd/gateway-main.go +++ b/cmd/gateway-main.go @@ -157,28 +157,6 @@ func StartGateway(ctx *cli.Context, gw Gateway) { // Create certs path. logger.FatalIf(createConfigDir(), "Unable to create configuration directories") - // Initialize server config. - srvCfg := newServerConfig() - - // Override any values from ENVs. - srvCfg.loadFromEnvs() - - // Load values to cached global values. - srvCfg.loadToCachedConfigs() - - // hold the mutex lock before a new config is assigned. - globalServerConfigMu.Lock() - globalServerConfig = srvCfg - globalServerConfigMu.Unlock() - - var cacheConfig = globalServerConfig.GetCacheConfig() - if len(cacheConfig.Drives) > 0 { - var err error - // initialize the new disk cache objects. - globalCacheObjectAPI, err = newServerCacheObjects(cacheConfig) - logger.FatalIf(err, "Unable to initialize disk caching") - } - // Check and load SSL certificates. var err error globalPublicCerts, globalRootCAs, globalTLSCerts, globalIsSSL, err = getSSLConfig() @@ -189,12 +167,6 @@ func StartGateway(ctx *cli.Context, gw Gateway) { initNSLock(false) // Enable local namespace lock. - // Create new notification system. - globalNotificationSys = NewNotificationSys(globalServerConfig, EndpointList{}) - - // Create new policy system. - globalPolicySys = NewPolicySys() - router := mux.NewRouter().SkipClean(true) // Add healthcheck router @@ -208,6 +180,11 @@ func StartGateway(ctx *cli.Context, gw Gateway) { logger.FatalIf(registerWebRouter(router), "Unable to configure web browser") } + // Enable STS router if etcd is enabled. + if globalEtcdClient != nil { + registerSTSRouter(router) + } + // Add API router. registerAPIRouter(router) @@ -234,8 +211,52 @@ func StartGateway(ctx *cli.Context, gw Gateway) { logger.FatalIf(err, "Unable to initialize gateway backend") } + // Create a new config system. + globalConfigSys = NewConfigSys() + + // Initialize server config. + srvCfg := newServerConfig() + + // Override any values from ENVs. + srvCfg.loadFromEnvs() + + // Load values to cached global values. + srvCfg.loadToCachedConfigs() + + // hold the mutex lock before a new config is assigned. + globalServerConfigMu.Lock() + globalServerConfig = srvCfg + globalServerConfigMu.Unlock() + + var cacheConfig = globalServerConfig.GetCacheConfig() + if len(cacheConfig.Drives) > 0 { + var err error + // initialize the new disk cache objects. + globalCacheObjectAPI, err = newServerCacheObjects(cacheConfig) + logger.FatalIf(err, "Unable to initialize disk caching") + } + + // Load logger subsystem + loadLoggers() + + // Re-enable logging + logger.Disable = false + + // Create new IAM system. + globalIAMSys = NewIAMSys() + + // Initialize IAM sys. + go globalIAMSys.Init(newObject) + + // Create new policy system. + globalPolicySys = NewPolicySys() + + // Initialize policy system. go globalPolicySys.Init(newObject) + // Create new notification system. + globalNotificationSys = NewNotificationSys(globalServerConfig, globalEndpoints) + // Once endpoints are finalized, initialize the new object api. globalObjLayerMutex.Lock() globalObjectAPI = newObject @@ -256,8 +277,5 @@ func StartGateway(ctx *cli.Context, gw Gateway) { printGatewayStartupMessage(getAPIEndpoints(gatewayAddr), gatewayName) } - // Reenable logging - logger.Disable = false - handleSignals() } diff --git a/cmd/globals.go b/cmd/globals.go index fb50bda3c..d9c35c581 100644 --- a/cmd/globals.go +++ b/cmd/globals.go @@ -34,6 +34,8 @@ import ( "github.com/minio/minio/pkg/auth" "github.com/minio/minio/pkg/certs" "github.com/minio/minio/pkg/dns" + "github.com/minio/minio/pkg/iam/policy" + "github.com/minio/minio/pkg/iam/validator" ) // minio configuration related constants. @@ -81,6 +83,8 @@ const ( globalMultipartCleanupInterval = time.Hour * 24 // 24 hrs. // Refresh interval to update in-memory bucket policy cache. globalRefreshBucketPolicyInterval = 5 * time.Minute + // Refresh interval to update in-memory iam config cache. + globalRefreshIAMInterval = 5 * time.Minute // Limit of location constraint XML for unauthenticted PUT bucket operations. maxLocationConstraintSize = 3 * humanize.MiByte @@ -132,6 +136,7 @@ var ( globalNotificationSys *NotificationSys globalPolicySys *PolicySys + globalIAMSys *IAMSys // CA root certificates, a nil value means system certs pool will be used globalRootCAs *x509.CertPool @@ -244,6 +249,12 @@ var ( // Some standard content-types which we strictly dis-allow for compression. standardExcludeCompressContentTypes = []string{"video/*", "audio/*", "application/zip", "application/x-gzip", "application/x-zip-compressed", " application/x-compress", "application/x-spoon"} + // Authorization validators list. + globalIAMValidators *validator.Validators + + // OPA policy system. + globalPolicyOPA *iampolicy.Opa + // Add new variable global values here. ) diff --git a/cmd/iam.go b/cmd/iam.go new file mode 100644 index 000000000..c1c50197c --- /dev/null +++ b/cmd/iam.go @@ -0,0 +1,352 @@ +/* + * Minio Cloud Storage, (C) 2018 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 ( + "context" + "encoding/json" + "path" + "sync" + "time" + + "github.com/minio/minio/cmd/logger" + "github.com/minio/minio/pkg/auth" + "github.com/minio/minio/pkg/iam/policy" + "github.com/minio/minio/pkg/madmin" +) + +const ( + // IAM configuration directory. + iamConfigPrefix = minioConfigPrefix + "/iam" + + // IAM users directory. + iamConfigUsersPrefix = iamConfigPrefix + "/users/" + + // IAM sts directory. + iamConfigSTSPrefix = iamConfigPrefix + "/sts/" + + // IAM identity file which captures identity credentials. + iamIdentityFile = "identity.json" + + // IAM policy file which provides policies for each users. + iamPolicyFile = "policy.json" +) + +// IAMSys - config system. +type IAMSys struct { + sync.RWMutex + iamUsersMap map[string]auth.Credentials + iamPolicyMap map[string]iampolicy.Policy +} + +// Load - load iam.json +func (sys *IAMSys) Load(objAPI ObjectLayer) error { + return sys.Init(objAPI) +} + +// Init - initializes config system from iam.json +func (sys *IAMSys) Init(objAPI ObjectLayer) error { + if objAPI == nil { + return errInvalidArgument + } + + if err := sys.refresh(objAPI); err != nil { + return err + } + + // Refresh IAMSys in background. + go func() { + ticker := time.NewTicker(globalRefreshIAMInterval) + defer ticker.Stop() + for { + select { + case <-globalServiceDoneCh: + return + case <-ticker.C: + logger.LogIf(context.Background(), sys.refresh(objAPI)) + } + } + }() + return nil + +} + +// SetPolicy - sets policy to given user name. If policy is empty, +// existing policy is removed. +func (sys *IAMSys) SetPolicy(accessKey string, p iampolicy.Policy) error { + objectAPI := newObjectLayerFn() + if objectAPI == nil { + return errServerNotInitialized + } + + configFile := pathJoin(iamConfigUsersPrefix, accessKey, iamPolicyFile) + data, err := json.Marshal(p) + if err != nil { + return err + } + + sys.Lock() + defer sys.Unlock() + + if err = saveConfig(context.Background(), objectAPI, configFile, data); err != nil { + return err + } + + if p.IsEmpty() { + delete(sys.iamPolicyMap, accessKey) + } else { + sys.iamPolicyMap[accessKey] = p + } + + return nil +} + +// SaveTempPolicy - this is used for temporary credentials only. +func (sys *IAMSys) SaveTempPolicy(accessKey string, p iampolicy.Policy) error { + objectAPI := newObjectLayerFn() + if objectAPI == nil { + return errServerNotInitialized + } + + configFile := pathJoin(iamConfigSTSPrefix, accessKey, iamPolicyFile) + data, err := json.Marshal(p) + if err != nil { + return err + } + + sys.Lock() + defer sys.Unlock() + + if err = saveConfig(context.Background(), objectAPI, configFile, data); err != nil { + return err + } + + if p.IsEmpty() { + delete(sys.iamPolicyMap, accessKey) + } else { + sys.iamPolicyMap[accessKey] = p + } + + return nil +} + +// DeletePolicy - sets policy to given user name. If policy is empty, +// existing policy is removed. +func (sys *IAMSys) DeletePolicy(accessKey string) error { + objectAPI := newObjectLayerFn() + if objectAPI == nil { + return errServerNotInitialized + } + + configFile := pathJoin(iamConfigUsersPrefix, accessKey, iamPolicyFile) + + sys.Lock() + defer sys.Unlock() + + err := objectAPI.DeleteObject(context.Background(), minioMetaBucket, configFile) + + delete(sys.iamPolicyMap, accessKey) + + return err +} + +// DeleteUser - set user credentials. +func (sys *IAMSys) DeleteUser(accessKey string) error { + objectAPI := newObjectLayerFn() + if objectAPI == nil { + return errServerNotInitialized + } + + sys.Lock() + defer sys.Unlock() + + configFile := pathJoin(iamConfigUsersPrefix, accessKey, iamIdentityFile) + err := objectAPI.DeleteObject(context.Background(), minioMetaBucket, configFile) + + delete(sys.iamUsersMap, accessKey) + return err +} + +// SetTempUser - set temporary user credentials, these credentials have an expiry. +func (sys *IAMSys) SetTempUser(accessKey string, cred auth.Credentials) error { + objectAPI := newObjectLayerFn() + if objectAPI == nil { + return errServerNotInitialized + } + + sys.Lock() + defer sys.Unlock() + + configFile := pathJoin(iamConfigSTSPrefix, accessKey, iamIdentityFile) + data, err := json.Marshal(cred) + if err != nil { + return err + } + + if err = saveConfig(context.Background(), objectAPI, configFile, data); err != nil { + return err + } + + sys.iamUsersMap[accessKey] = cred + return nil +} + +// SetUser - set user credentials. +func (sys *IAMSys) SetUser(accessKey string, uinfo madmin.UserInfo) error { + objectAPI := newObjectLayerFn() + if objectAPI == nil { + return errServerNotInitialized + } + + configFile := pathJoin(iamConfigUsersPrefix, accessKey, iamIdentityFile) + data, err := json.Marshal(uinfo) + if err != nil { + return err + } + + sys.Lock() + defer sys.Unlock() + + if err = saveConfig(context.Background(), objectAPI, configFile, data); err != nil { + return err + } + + sys.iamUsersMap[accessKey] = auth.Credentials{ + AccessKey: accessKey, + SecretKey: uinfo.SecretKey, + Status: string(uinfo.Status), + } + + return nil +} + +// GetUser - get user credentials +func (sys *IAMSys) GetUser(accessKey string) (cred auth.Credentials, ok bool) { + sys.RLock() + defer sys.RUnlock() + + cred, ok = sys.iamUsersMap[accessKey] + return cred, ok && cred.IsValid() +} + +// IsAllowed - checks given policy args is allowed to continue the Rest API. +func (sys *IAMSys) IsAllowed(args iampolicy.Args) bool { + sys.RLock() + defer sys.RUnlock() + + // If policy is available for given user, check the policy. + if p, found := sys.iamPolicyMap[args.AccountName]; found { + // If opa is configured, use OPA in conjunction with IAM policies. + if globalPolicyOPA != nil { + return p.IsAllowed(args) && globalPolicyOPA.IsAllowed(args) + } + return p.IsAllowed(args) + } + + // If no policies are set, let the policy arrive from OPA if any. + if globalPolicyOPA != nil { + return globalPolicyOPA.IsAllowed(args) + } + + // As policy is not available and OPA is not configured, return the owner value. + return args.IsOwner +} + +// reloadUsers reads an updates users, policies from object layer into user and policy maps. +func reloadUsers(objectAPI ObjectLayer, prefix string, usersMap map[string]auth.Credentials, policyMap map[string]iampolicy.Policy) error { + marker := "" + for { + var lo ListObjectsInfo + var err error + lo, err = objectAPI.ListObjects(context.Background(), minioMetaBucket, prefix, marker, "/", 1000) + if err != nil { + return err + } + marker = lo.NextMarker + for _, prefix := range lo.Prefixes { + idFile := pathJoin(prefix, iamIdentityFile) + pFile := pathJoin(prefix, iamPolicyFile) + cdata, cerr := readConfig(context.Background(), objectAPI, idFile) + pdata, perr := readConfig(context.Background(), objectAPI, pFile) + if cerr != nil && cerr != errConfigNotFound { + return cerr + } + if perr != nil && perr != errConfigNotFound { + return perr + } + if cerr == errConfigNotFound && perr == errConfigNotFound { + continue + } + if cerr == nil { + var cred auth.Credentials + if err = json.Unmarshal(cdata, &cred); err != nil { + return err + } + cred.AccessKey = path.Base(prefix) + if cred.IsExpired() { + // Delete expired identity. + objectAPI.DeleteObject(context.Background(), minioMetaBucket, idFile) + // Delete expired identity policy. + objectAPI.DeleteObject(context.Background(), minioMetaBucket, pFile) + continue + } + usersMap[cred.AccessKey] = cred + } + if perr == nil { + var p iampolicy.Policy + if err = json.Unmarshal(pdata, &p); err != nil { + return err + } + policyMap[path.Base(prefix)] = p + } + } + if !lo.IsTruncated { + break + } + } + return nil +} + +// Refresh IAMSys. +func (sys *IAMSys) refresh(objAPI ObjectLayer) error { + iamUsersMap := make(map[string]auth.Credentials) + iamPolicyMap := make(map[string]iampolicy.Policy) + + if err := reloadUsers(objAPI, iamConfigUsersPrefix, iamUsersMap, iamPolicyMap); err != nil { + return err + } + + if err := reloadUsers(objAPI, iamConfigSTSPrefix, iamUsersMap, iamPolicyMap); err != nil { + return err + } + + sys.Lock() + defer sys.Unlock() + + sys.iamUsersMap = iamUsersMap + sys.iamPolicyMap = iamPolicyMap + + return nil +} + +// NewIAMSys - creates new config system object. +func NewIAMSys() *IAMSys { + return &IAMSys{ + iamUsersMap: make(map[string]auth.Credentials), + iamPolicyMap: make(map[string]iampolicy.Policy), + } +} diff --git a/cmd/jwt.go b/cmd/jwt.go index ca18fd6a6..e8079e0b3 100644 --- a/cmd/jwt.go +++ b/cmd/jwt.go @@ -84,57 +84,127 @@ func authenticateURL(accessKey, secretKey string) (string, error) { return authenticateJWT(accessKey, secretKey, defaultURLJWTExpiry) } -func keyFuncCallback(jwtToken *jwtgo.Token) (interface{}, error) { +func stsTokenCallback(jwtToken *jwtgo.Token) (interface{}, error) { if _, ok := jwtToken.Method.(*jwtgo.SigningMethodHMAC); !ok { return nil, fmt.Errorf("Unexpected signing method: %v", jwtToken.Header["alg"]) } - return []byte(globalServerConfig.GetCredential().SecretKey), nil + if err := jwtToken.Claims.Valid(); err != nil { + return nil, errAuthentication + } + if claims, ok := jwtToken.Claims.(jwtgo.MapClaims); ok { + accessKey, ok := claims["accessKey"].(string) + if !ok { + return nil, errInvalidAccessKeyID + } + if accessKey == globalServerConfig.GetCredential().AccessKey { + return []byte(globalServerConfig.GetCredential().SecretKey), nil + } + if globalIAMSys == nil { + return nil, errInvalidAccessKeyID + } + _, ok = globalIAMSys.GetUser(accessKey) + if !ok { + return nil, errInvalidAccessKeyID + } + return []byte(globalServerConfig.GetCredential().SecretKey), nil + } + return nil, errAuthentication } -func isAuthTokenValid(tokenString string) bool { - if tokenString == "" { - return false +// Callback function used for parsing +func webTokenCallback(jwtToken *jwtgo.Token) (interface{}, error) { + if _, ok := jwtToken.Method.(*jwtgo.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("Unexpected signing method: %v", jwtToken.Header["alg"]) + } + + if err := jwtToken.Claims.Valid(); err != nil { + return nil, errAuthentication + } + + if claims, ok := jwtToken.Claims.(*jwtgo.StandardClaims); ok { + if claims.Subject == globalServerConfig.GetCredential().AccessKey { + return []byte(globalServerConfig.GetCredential().SecretKey), nil + } + if globalIAMSys == nil { + return nil, errInvalidAccessKeyID + } + cred, ok := globalIAMSys.GetUser(claims.Subject) + if !ok { + return nil, errInvalidAccessKeyID + } + return []byte(cred.SecretKey), nil } - var claims jwtgo.StandardClaims - jwtToken, err := jwtgo.ParseWithClaims(tokenString, &claims, keyFuncCallback) + + return nil, errAuthentication +} + +func parseJWTWithClaims(tokenString string, claims jwtgo.Claims) (*jwtgo.Token, error) { + p := &jwtgo.Parser{ + SkipClaimsValidation: true, + } + jwtToken, err := p.ParseWithClaims(tokenString, claims, webTokenCallback) if err != nil { - logger.LogIf(context.Background(), err) - return false + switch e := err.(type) { + case *jwtgo.ValidationError: + if e.Inner == nil { + return nil, errAuthentication + } + return nil, e.Inner + } + return nil, errAuthentication } - if err = claims.Valid(); err != nil { - logger.LogIf(context.Background(), err) - return false + return jwtToken, nil +} + +func isAuthTokenValid(token string) bool { + _, _, err := webTokenAuthenticate(token) + return err == nil +} + +func webTokenAuthenticate(token string) (jwtgo.StandardClaims, bool, error) { + var claims = jwtgo.StandardClaims{} + if token == "" { + return claims, false, errNoAuthToken } - return jwtToken.Valid && claims.Subject == globalServerConfig.GetCredential().AccessKey + + jwtToken, err := parseJWTWithClaims(token, &claims) + if err != nil { + return claims, false, err + } + if !jwtToken.Valid { + return claims, false, errAuthentication + } + owner := claims.Subject == globalServerConfig.GetCredential().AccessKey + return claims, owner, nil } func isHTTPRequestValid(req *http.Request) bool { - return webRequestAuthenticate(req) == nil + _, _, err := webRequestAuthenticate(req) + return err == nil } // Check if the request is authenticated. // Returns nil if the request is authenticated. errNoAuthToken if token missing. // Returns errAuthentication for all other errors. -func webRequestAuthenticate(req *http.Request) error { - var claims jwtgo.StandardClaims - jwtToken, err := jwtreq.ParseFromRequestWithClaims(req, jwtreq.AuthorizationHeaderExtractor, &claims, keyFuncCallback) +func webRequestAuthenticate(req *http.Request) (jwtgo.StandardClaims, bool, error) { + var claims = jwtgo.StandardClaims{} + tokStr, err := jwtreq.AuthorizationHeaderExtractor.ExtractToken(req) if err != nil { if err == jwtreq.ErrNoTokenInRequest { - return errNoAuthToken + return claims, false, errNoAuthToken } - return errAuthentication - } - if err = claims.Valid(); err != nil { - return errAuthentication + return claims, false, err } - if claims.Subject != globalServerConfig.GetCredential().AccessKey { - return errInvalidAccessKeyID + jwtToken, err := parseJWTWithClaims(tokStr, &claims) + if err != nil { + return claims, false, err } if !jwtToken.Valid { - return errAuthentication + return claims, false, errAuthentication } - return nil + owner := claims.Subject == globalServerConfig.GetCredential().AccessKey + return claims, owner, nil } func newAuthToken() string { diff --git a/cmd/jwt_test.go b/cmd/jwt_test.go index 2c311fccb..910e97d8d 100644 --- a/cmd/jwt_test.go +++ b/cmd/jwt_test.go @@ -142,7 +142,7 @@ func TestWebRequestAuthenticate(t *testing.T) { } for i, testCase := range testCases { - gotErr := webRequestAuthenticate(testCase.req) + _, _, gotErr := webRequestAuthenticate(testCase.req) if testCase.expectedErr != gotErr { t.Errorf("Test %d, expected err %s, got %s", i+1, testCase.expectedErr, gotErr) } diff --git a/cmd/notification.go b/cmd/notification.go index 74dc4d11e..dc9df91f4 100644 --- a/cmd/notification.go +++ b/cmd/notification.go @@ -17,6 +17,7 @@ package cmd import ( + "bytes" "context" "encoding/json" "encoding/xml" @@ -246,15 +247,14 @@ func (sys *NotificationSys) initListeners(ctx context.Context, objAPI ObjectLaye } defer objLock.RUnlock() - reader, e := readConfig(ctx, objAPI, configFile) + configData, e := readConfig(ctx, objAPI, configFile) if e != nil && !IsErrIgnored(e, errDiskNotFound, errConfigNotFound) { return e } listenerList := []ListenBucketNotificationArgs{} - if reader != nil { - err := json.NewDecoder(reader).Decode(&listenerList) - if err != nil { + if configData != nil { + if err := json.Unmarshal(configData, &listenerList); err != nil { logger.LogIf(ctx, err) return err } @@ -565,7 +565,7 @@ func sendEvent(args eventArgs) { func readNotificationConfig(ctx context.Context, objAPI ObjectLayer, bucketName string) (*event.Config, error) { // Construct path to notification.xml for the given bucket. configFile := path.Join(bucketConfigPrefix, bucketName, bucketNotificationConfig) - reader, err := readConfig(ctx, objAPI, configFile) + configData, err := readConfig(ctx, objAPI, configFile) if err != nil { if err == errConfigNotFound { err = errNoSuchNotifications @@ -574,19 +574,19 @@ func readNotificationConfig(ctx context.Context, objAPI ObjectLayer, bucketName return nil, err } - config, err := event.ParseConfig(reader, globalServerConfig.GetRegion(), globalNotificationSys.targetList) + config, err := event.ParseConfig(bytes.NewReader(configData), globalServerConfig.GetRegion(), globalNotificationSys.targetList) logger.LogIf(ctx, err) return config, err } -func saveNotificationConfig(objAPI ObjectLayer, bucketName string, config *event.Config) error { +func saveNotificationConfig(ctx context.Context, objAPI ObjectLayer, bucketName string, config *event.Config) error { data, err := xml.Marshal(config) if err != nil { return err } configFile := path.Join(bucketConfigPrefix, bucketName, bucketNotificationConfig) - return saveConfig(objAPI, configFile, data) + return saveConfig(ctx, objAPI, configFile, data) } // SaveListener - saves HTTP client currently listening for events to listener.json. @@ -611,14 +611,14 @@ func SaveListener(objAPI ObjectLayer, bucketName string, eventNames []event.Name } defer objLock.Unlock() - reader, err := readConfig(ctx, objAPI, configFile) + configData, err := readConfig(ctx, objAPI, configFile) if err != nil && !IsErrIgnored(err, errDiskNotFound, errConfigNotFound) { return err } listenerList := []ListenBucketNotificationArgs{} - if reader != nil { - if err = json.NewDecoder(reader).Decode(&listenerList); err != nil { + if configData != nil { + if err = json.Unmarshal(configData, &listenerList); err != nil { logger.LogIf(ctx, err) return err } @@ -637,7 +637,7 @@ func SaveListener(objAPI ObjectLayer, bucketName string, eventNames []event.Name return err } - return saveConfig(objAPI, configFile, data) + return saveConfig(ctx, objAPI, configFile, data) } // RemoveListener - removes HTTP client currently listening for events from listener.json. @@ -662,14 +662,14 @@ func RemoveListener(objAPI ObjectLayer, bucketName string, targetID event.Target } defer objLock.Unlock() - reader, err := readConfig(ctx, objAPI, configFile) + configData, err := readConfig(ctx, objAPI, configFile) if err != nil && !IsErrIgnored(err, errDiskNotFound, errConfigNotFound) { return err } listenerList := []ListenBucketNotificationArgs{} - if reader != nil { - if err = json.NewDecoder(reader).Decode(&listenerList); err != nil { + if configData != nil { + if err = json.Unmarshal(configData, &listenerList); err != nil { logger.LogIf(ctx, err) return err } @@ -696,5 +696,5 @@ func RemoveListener(objAPI ObjectLayer, bucketName string, targetID event.Target return err } - return saveConfig(objAPI, configFile, data) + return saveConfig(ctx, objAPI, configFile, data) } diff --git a/cmd/object-api-errors.go b/cmd/object-api-errors.go index 58bd1de6b..32a3d92f6 100644 --- a/cmd/object-api-errors.go +++ b/cmd/object-api-errors.go @@ -378,12 +378,6 @@ func (e BackendDown) Error() string { return "Backend down" } -// isErrIncompleteBody - Check if error type is IncompleteBody. -func isErrIncompleteBody(err error) bool { - _, ok := err.(IncompleteBody) - return ok -} - // isErrObjectNotFound - Check if error type is ObjectNotFound. func isErrObjectNotFound(err error) bool { _, ok := err.(ObjectNotFound) diff --git a/cmd/object-handlers.go b/cmd/object-handlers.go index 203ffcd2a..f49f4e7a0 100644 --- a/cmd/object-handlers.go +++ b/cmd/object-handlers.go @@ -1057,22 +1057,14 @@ func (api objectAPIHandlers) PutObjectHandler(w http.ResponseWriter, r *http.Req putObject = objectAPI.PutObject ) reader = r.Body + + // Check if put is allowed + if s3Err = isPutAllowed(rAuthType, bucket, object, r); s3Err != ErrNone { + writeErrorResponse(w, s3Err, r.URL) + return + } + switch rAuthType { - default: - // For all unknown auth types return error. - writeErrorResponse(w, ErrAccessDenied, r.URL) - return - case authTypeAnonymous: - if !globalPolicySys.IsAllowed(policy.Args{ - Action: policy.PutObjectAction, - BucketName: bucket, - ConditionValues: getConditionValues(r, ""), - IsOwner: false, - ObjectName: object, - }) { - writeErrorResponse(w, ErrAccessDenied, r.URL) - return - } case authTypeStreamingSigned: // Initialize stream signature verifier. reader, s3Err = newSignV4ChunkedReader(r) @@ -1119,7 +1111,6 @@ func (api objectAPIHandlers) PutObjectHandler(w http.ResponseWriter, r *http.Req _, cerr := io.CopyN(snappyWriter, actualReader, actualSize) snappyWriter.Close() pipeWriter.CloseWithError(cerr) - }() // Set compression metrics. @@ -1611,41 +1602,30 @@ func (api objectAPIHandlers) PutObjectPartHandler(w http.ResponseWriter, r *http md5hex = hex.EncodeToString(md5Bytes) sha256hex = "" reader io.Reader + s3Error APIErrorCode ) reader = r.Body + if s3Error = isPutAllowed(rAuthType, bucket, object, r); s3Error != ErrNone { + writeErrorResponse(w, s3Error, r.URL) + return + } + switch rAuthType { - default: - // For all unknown auth types return error. - writeErrorResponse(w, ErrAccessDenied, r.URL) - return - case authTypeAnonymous: - if !globalPolicySys.IsAllowed(policy.Args{ - Action: policy.PutObjectAction, - BucketName: bucket, - ConditionValues: getConditionValues(r, ""), - IsOwner: false, - ObjectName: object, - }) { - writeErrorResponse(w, ErrAccessDenied, r.URL) - return - } case authTypeStreamingSigned: // Initialize stream signature verifier. - var s3Error APIErrorCode reader, s3Error = newSignV4ChunkedReader(r) if s3Error != ErrNone { writeErrorResponse(w, s3Error, r.URL) return } case authTypeSignedV2, authTypePresignedV2: - s3Error := isReqAuthenticatedV2(r) - if s3Error != ErrNone { + if s3Error = isReqAuthenticatedV2(r); s3Error != ErrNone { writeErrorResponse(w, s3Error, r.URL) return } case authTypePresigned, authTypeSigned: - if s3Error := reqSignatureV4Verify(r, globalServerConfig.GetRegion()); s3Error != ErrNone { + if s3Error = reqSignatureV4Verify(r, globalServerConfig.GetRegion()); s3Error != ErrNone { writeErrorResponse(w, s3Error, r.URL) return } diff --git a/cmd/policy.go b/cmd/policy.go index 5440fca1d..e5fbe4d1e 100644 --- a/cmd/policy.go +++ b/cmd/policy.go @@ -17,6 +17,7 @@ package cmd import ( + "bytes" "context" "encoding/json" "net/http" @@ -116,7 +117,7 @@ func (sys *PolicySys) refresh(objAPI ObjectLayer) error { logger.Info("Found in-consistent bucket policies, Migrating them for Bucket: (%s)", bucket.Name) config.Version = policy.DefaultVersion - if err = savePolicyConfig(objAPI, bucket.Name, config); err != nil { + if err = savePolicyConfig(context.Background(), objAPI, bucket.Name, config); err != nil { logger.LogIf(context.Background(), err) return err } @@ -214,7 +215,7 @@ func getPolicyConfig(objAPI ObjectLayer, bucketName string) (*policy.Policy, err // Construct path to policy.json for the given bucket. configFile := path.Join(bucketConfigPrefix, bucketName, bucketPolicyConfig) - reader, err := readConfig(context.Background(), objAPI, configFile) + configData, err := readConfig(context.Background(), objAPI, configFile) if err != nil { if err == errConfigNotFound { err = BucketPolicyNotFound{Bucket: bucketName} @@ -223,10 +224,10 @@ func getPolicyConfig(objAPI ObjectLayer, bucketName string) (*policy.Policy, err return nil, err } - return policy.ParseConfig(reader, bucketName) + return policy.ParseConfig(bytes.NewReader(configData), bucketName) } -func savePolicyConfig(objAPI ObjectLayer, bucketName string, bucketPolicy *policy.Policy) error { +func savePolicyConfig(ctx context.Context, objAPI ObjectLayer, bucketName string, bucketPolicy *policy.Policy) error { data, err := json.Marshal(bucketPolicy) if err != nil { return err @@ -235,7 +236,7 @@ func savePolicyConfig(objAPI ObjectLayer, bucketName string, bucketPolicy *polic // Construct path to policy.json for the given bucket. configFile := path.Join(bucketConfigPrefix, bucketName, bucketPolicyConfig) - return saveConfig(objAPI, configFile, data) + return saveConfig(ctx, objAPI, configFile, data) } func removePolicyConfig(ctx context.Context, objAPI ObjectLayer, bucketName string) error { diff --git a/cmd/routers.go b/cmd/routers.go index 381706c7e..8a21dba06 100644 --- a/cmd/routers.go +++ b/cmd/routers.go @@ -99,6 +99,9 @@ func configureServerHandler(endpoints EndpointList) (http.Handler, error) { registerDistXLRouters(router, endpoints) } + // Add STS router only enabled if etcd is configured. + registerSTSRouter(router) + // Add Admin RPC router registerAdminRPCRouter(router) diff --git a/cmd/server-main.go b/cmd/server-main.go index 1f94e739a..5309e523f 100644 --- a/cmd/server-main.go +++ b/cmd/server-main.go @@ -358,11 +358,17 @@ func serverMain(ctx *cli.Context) { logger.FatalIf(err, "Unable to initialize disk caching") } + // Create new IAM system. + globalIAMSys = NewIAMSys() + if err = globalIAMSys.Init(newObject); err != nil { + logger.Fatal(err, "Unable to initialize IAM system") + } + // Create new policy system. globalPolicySys = NewPolicySys() // Initialize policy system. - if err := globalPolicySys.Init(newObject); err != nil { + if err = globalPolicySys.Init(newObject); err != nil { logger.Fatal(err, "Unable to initialize policy system") } @@ -370,7 +376,7 @@ func serverMain(ctx *cli.Context) { globalNotificationSys = NewNotificationSys(globalServerConfig, globalEndpoints) // Initialize notification system. - if err := globalNotificationSys.Init(newObject); err != nil { + if err = globalNotificationSys.Init(newObject); err != nil { logger.Fatal(err, "Unable to initialize notification system") } diff --git a/cmd/signature-v2.go b/cmd/signature-v2.go index d9504ec57..d1ce20897 100644 --- a/cmd/signature-v2.go +++ b/cmd/signature-v2.go @@ -27,6 +27,8 @@ import ( "sort" "strconv" "strings" + + "github.com/minio/minio/pkg/auth" ) // Signature and API related constants. @@ -68,7 +70,14 @@ func doesPolicySignatureV2Match(formValues http.Header) APIErrorCode { cred := globalServerConfig.GetCredential() accessKey := formValues.Get("AWSAccessKeyId") if accessKey != cred.AccessKey { - return ErrInvalidAccessKeyID + if globalIAMSys == nil { + return ErrInvalidAccessKeyID + } + var ok bool + cred, ok = globalIAMSys.GetUser(accessKey) + if !ok { + return ErrInvalidAccessKeyID + } } policy := formValues.Get("Policy") signature := formValues.Get("Signature") @@ -146,7 +155,14 @@ func doesPresignV2SignatureMatch(r *http.Request) APIErrorCode { // Validate if access key id same. if accessKey != cred.AccessKey { - return ErrInvalidAccessKeyID + if globalIAMSys == nil { + return ErrInvalidAccessKeyID + } + var ok bool + cred, ok = globalIAMSys.GetUser(accessKey) + if !ok { + return ErrInvalidAccessKeyID + } } // Make sure the request has not expired. @@ -165,7 +181,7 @@ func doesPresignV2SignatureMatch(r *http.Request) APIErrorCode { return ErrInvalidRequest } - expectedSignature := preSignatureV2(r.Method, encodedResource, strings.Join(filteredQueries, "&"), r.Header, expires) + expectedSignature := preSignatureV2(cred, r.Method, encodedResource, strings.Join(filteredQueries, "&"), r.Header, expires) if !compareSignatureV2(gotSignature, expectedSignature) { return ErrSignatureDoesNotMatch } @@ -173,6 +189,29 @@ func doesPresignV2SignatureMatch(r *http.Request) APIErrorCode { return ErrNone } +func getReqAccessKeyV2(r *http.Request) (string, bool, APIErrorCode) { + if accessKey := r.URL.Query().Get("AWSAccessKeyId"); accessKey != "" { + owner, s3Err := checkKeyValid(accessKey) + return accessKey, owner, s3Err + } + + // below is V2 Signed Auth header format, splitting on `space` (after the `AWS` string). + // Authorization = "AWS" + " " + AWSAccessKeyId + ":" + Signature + authFields := strings.Split(r.Header.Get("Authorization"), " ") + if len(authFields) != 2 { + return "", false, ErrMissingFields + } + + // Then will be splitting on ":", this will seprate `AWSAccessKeyId` and `Signature` string. + keySignFields := strings.Split(strings.TrimSpace(authFields[1]), ":") + if len(keySignFields) != 2 { + return "", false, ErrMissingFields + } + + owner, s3Err := checkKeyValid(keySignFields[0]) + return keySignFields[0], owner, s3Err +} + // Authorization = "AWS" + " " + AWSAccessKeyId + ":" + Signature; // Signature = Base64( HMAC-SHA1( YourSecretKey, UTF-8-Encoding-Of( StringToSign ) ) ); // @@ -193,41 +232,43 @@ func doesPresignV2SignatureMatch(r *http.Request) APIErrorCode { // - http://docs.aws.amazon.com/AmazonS3/latest/dev/auth-request-sig-v2.html // returns true if matches, false otherwise. if error is not nil then it is always false -func validateV2AuthHeader(v2Auth string) APIErrorCode { +func validateV2AuthHeader(r *http.Request) (auth.Credentials, APIErrorCode) { + var cred auth.Credentials + v2Auth := r.Header.Get("Authorization") if v2Auth == "" { - return ErrAuthHeaderEmpty + return cred, ErrAuthHeaderEmpty } + // Verify if the header algorithm is supported or not. if !strings.HasPrefix(v2Auth, signV2Algorithm) { - return ErrSignatureVersionNotSupported + return cred, ErrSignatureVersionNotSupported } - // below is V2 Signed Auth header format, splitting on `space` (after the `AWS` string). - // Authorization = "AWS" + " " + AWSAccessKeyId + ":" + Signature - authFields := strings.Split(v2Auth, " ") - if len(authFields) != 2 { - return ErrMissingFields - } - - // Then will be splitting on ":", this will seprate `AWSAccessKeyId` and `Signature` string. - keySignFields := strings.Split(strings.TrimSpace(authFields[1]), ":") - if len(keySignFields) != 2 { - return ErrMissingFields + accessKey, owner, apiErr := getReqAccessKeyV2(r) + if apiErr != ErrNone { + return cred, apiErr } + cred = globalServerConfig.GetCredential() // Access credentials. - cred := globalServerConfig.GetCredential() - if keySignFields[0] != cred.AccessKey { - return ErrInvalidAccessKeyID + if !owner { + if globalIAMSys == nil { + return cred, ErrInvalidAccessKeyID + } + var ok bool + cred, ok = globalIAMSys.GetUser(accessKey) + if !ok { + return cred, ErrInvalidAccessKeyID + } } - return ErrNone + return cred, ErrNone } func doesSignV2Match(r *http.Request) APIErrorCode { v2Auth := r.Header.Get("Authorization") - - if apiError := validateV2AuthHeader(v2Auth); apiError != ErrNone { + cred, apiError := validateV2AuthHeader(r) + if apiError != ErrNone { return apiError } @@ -249,12 +290,12 @@ func doesSignV2Match(r *http.Request) APIErrorCode { return ErrInvalidRequest } - prefix := fmt.Sprintf("%s %s:", signV2Algorithm, globalServerConfig.GetCredential().AccessKey) + prefix := fmt.Sprintf("%s %s:", signV2Algorithm, cred.AccessKey) if !strings.HasPrefix(v2Auth, prefix) { return ErrSignatureDoesNotMatch } v2Auth = v2Auth[len(prefix):] - expectedAuth := signatureV2(r.Method, encodedResource, strings.Join(unescapedQueries, "&"), r.Header) + expectedAuth := signatureV2(cred, r.Method, encodedResource, strings.Join(unescapedQueries, "&"), r.Header) if !compareSignatureV2(v2Auth, expectedAuth) { return ErrSignatureDoesNotMatch } @@ -268,15 +309,13 @@ func calculateSignatureV2(stringToSign string, secret string) string { } // Return signature-v2 for the presigned request. -func preSignatureV2(method string, encodedResource string, encodedQuery string, headers http.Header, expires string) string { - cred := globalServerConfig.GetCredential() +func preSignatureV2(cred auth.Credentials, method string, encodedResource string, encodedQuery string, headers http.Header, expires string) string { stringToSign := getStringToSignV2(method, encodedResource, encodedQuery, headers, expires) return calculateSignatureV2(stringToSign, cred.SecretKey) } // Return the signature v2 of a given request. -func signatureV2(method string, encodedResource string, encodedQuery string, headers http.Header) string { - cred := globalServerConfig.GetCredential() +func signatureV2(cred auth.Credentials, method string, encodedResource string, encodedQuery string, headers http.Header) string { stringToSign := getStringToSignV2(method, encodedResource, encodedQuery, headers, "") signature := calculateSignatureV2(stringToSign, cred.SecretKey) return signature diff --git a/cmd/signature-v2_test.go b/cmd/signature-v2_test.go index e6a585d0a..87e24ae60 100644 --- a/cmd/signature-v2_test.go +++ b/cmd/signature-v2_test.go @@ -223,7 +223,12 @@ func TestValidateV2AuthHeader(t *testing.T) { for i, testCase := range testCases { t.Run(fmt.Sprintf("Case %d AuthStr \"%s\".", i+1, testCase.authString), func(t *testing.T) { - actualErrCode := validateV2AuthHeader(testCase.authString) + req := &http.Request{ + Header: make(http.Header), + URL: &url.URL{}, + } + req.Header.Set("Authorization", testCase.authString) + _, actualErrCode := validateV2AuthHeader(req) if testCase.expectedError != actualErrCode { t.Errorf("Expected the error code to be %v, got %v.", testCase.expectedError, actualErrCode) diff --git a/cmd/signature-v4-parser.go b/cmd/signature-v4-parser.go index 13b343f52..a94609c3b 100644 --- a/cmd/signature-v4-parser.go +++ b/cmd/signature-v4-parser.go @@ -17,6 +17,7 @@ package cmd import ( + "net/http" "net/url" "strings" "time" @@ -46,6 +47,24 @@ func (c credentialHeader) getScope() string { }, "/") } +func getReqAccessKeyV4(r *http.Request, region string) (string, bool, APIErrorCode) { + ch, err := parseCredentialHeader("Credential="+r.URL.Query().Get("X-Amz-Credential"), region) + if err != ErrNone { + // Strip off the Algorithm prefix. + v4Auth := strings.TrimPrefix(r.Header.Get("Authorization"), signV4Algorithm) + authFields := strings.Split(strings.TrimSpace(v4Auth), ",") + if len(authFields) != 3 { + return "", false, ErrMissingFields + } + ch, err = parseCredentialHeader(authFields[0], region) + if err != ErrNone { + return "", false, err + } + } + owner, s3Err := checkKeyValid(ch.accessKey) + return ch.accessKey, owner, s3Err +} + // parse credentialHeader string into its structured form. func parseCredentialHeader(credElement string, region string) (ch credentialHeader, aec APIErrorCode) { creds := strings.Split(strings.TrimSpace(credElement), "=") diff --git a/cmd/signature-v4-utils.go b/cmd/signature-v4-utils.go index 47675c6d2..0ffeb8723 100644 --- a/cmd/signature-v4-utils.go +++ b/cmd/signature-v4-utils.go @@ -100,6 +100,23 @@ func isValidRegion(reqRegion string, confRegion string) bool { return reqRegion == confRegion } +// check if the access key is valid and recognized, additionally +// also returns if the access key is owner/admin. +func checkKeyValid(accessKey string) (bool, APIErrorCode) { + var owner = true + if globalServerConfig.GetCredential().AccessKey != accessKey { + if globalIAMSys == nil { + return false, ErrInvalidAccessKeyID + } + // Check if the access key is part of users credentials. + if _, ok := globalIAMSys.GetUser(accessKey); !ok { + return false, ErrInvalidAccessKeyID + } + owner = false + } + return owner, ErrNone +} + // sumHMAC calculate hmac between two input byte array. func sumHMAC(key []byte, data []byte) []byte { hash := hmac.New(sha256.New, key) diff --git a/cmd/signature-v4.go b/cmd/signature-v4.go index f293acb69..b92b77812 100644 --- a/cmd/signature-v4.go +++ b/cmd/signature-v4.go @@ -175,7 +175,14 @@ func doesPolicySignatureV4Match(formValues http.Header) APIErrorCode { // Verify if the access key id matches. if credHeader.accessKey != cred.AccessKey { - return ErrInvalidAccessKeyID + if globalIAMSys == nil { + return ErrInvalidAccessKeyID + } + var ok bool + cred, ok = globalIAMSys.GetUser(credHeader.accessKey) + if !ok { + return ErrInvalidAccessKeyID + } } // Get signing key. @@ -211,7 +218,14 @@ func doesPresignedSignatureMatch(hashedPayload string, r *http.Request, region s // Verify if the access key id matches. if pSignValues.Credential.accessKey != cred.AccessKey { - return ErrInvalidAccessKeyID + if globalIAMSys == nil { + return ErrInvalidAccessKeyID + } + var ok bool + cred, ok = globalIAMSys.GetUser(pSignValues.Credential.accessKey) + if !ok { + return ErrInvalidAccessKeyID + } } // Extract all the signed headers along with its values. @@ -335,7 +349,14 @@ func doesSignatureMatch(hashedPayload string, r *http.Request, region string) AP // Verify if the access key id matches. if signV4Values.Credential.accessKey != cred.AccessKey { - return ErrInvalidAccessKeyID + if globalIAMSys == nil { + return ErrInvalidAccessKeyID + } + var ok bool + cred, ok = globalIAMSys.GetUser(signV4Values.Credential.accessKey) + if !ok { + return ErrInvalidAccessKeyID + } } // Extract date, if not present throw error. diff --git a/cmd/streaming-signature-v4.go b/cmd/streaming-signature-v4.go index 5e20f7b8a..1e2a26db1 100644 --- a/cmd/streaming-signature-v4.go +++ b/cmd/streaming-signature-v4.go @@ -64,7 +64,7 @@ func getChunkSignature(cred auth.Credentials, seedSignature string, region strin // - http://docs.aws.amazon.com/AmazonS3/latest/API/sigv4-streaming.html // returns signature, error otherwise if the signature mismatches or any other // error while parsing and validating. -func calculateSeedSignature(cred auth.Credentials, r *http.Request) (signature string, region string, date time.Time, errCode APIErrorCode) { +func calculateSeedSignature(r *http.Request) (cred auth.Credentials, signature string, region string, date time.Time, errCode APIErrorCode) { // Copy request. req := *r @@ -74,7 +74,7 @@ func calculateSeedSignature(cred auth.Credentials, r *http.Request) (signature s // Parse signature version '4' header. signV4Values, errCode := parseSignV4(v4Auth, globalServerConfig.GetRegion()) if errCode != ErrNone { - return "", "", time.Time{}, errCode + return cred, "", "", time.Time{}, errCode } // Payload streaming. @@ -82,17 +82,26 @@ func calculateSeedSignature(cred auth.Credentials, r *http.Request) (signature s // Payload for STREAMING signature should be 'STREAMING-AWS4-HMAC-SHA256-PAYLOAD' if payload != req.Header.Get("X-Amz-Content-Sha256") { - return "", "", time.Time{}, ErrContentSHA256Mismatch + return cred, "", "", time.Time{}, ErrContentSHA256Mismatch } // Extract all the signed headers along with its values. extractedSignedHeaders, errCode := extractSignedHeaders(signV4Values.SignedHeaders, r) if errCode != ErrNone { - return "", "", time.Time{}, errCode + return cred, "", "", time.Time{}, errCode } + + cred = globalServerConfig.GetCredential() // Verify if the access key id matches. if signV4Values.Credential.accessKey != cred.AccessKey { - return "", "", time.Time{}, ErrInvalidAccessKeyID + if globalIAMSys == nil { + return cred, "", "", time.Time{}, ErrInvalidAccessKeyID + } + var ok bool + cred, ok = globalIAMSys.GetUser(signV4Values.Credential.accessKey) + if !ok { + return cred, "", "", time.Time{}, ErrInvalidAccessKeyID + } } // Verify if region is valid. @@ -102,14 +111,14 @@ func calculateSeedSignature(cred auth.Credentials, r *http.Request) (signature s var dateStr string if dateStr = req.Header.Get(http.CanonicalHeaderKey("x-amz-date")); dateStr == "" { if dateStr = r.Header.Get("Date"); dateStr == "" { - return "", "", time.Time{}, ErrMissingDateHeader + return cred, "", "", time.Time{}, ErrMissingDateHeader } } // Parse date header. var err error date, err = time.Parse(iso8601Format, dateStr) if err != nil { - return "", "", time.Time{}, ErrMalformedDate + return cred, "", "", time.Time{}, ErrMalformedDate } // Query string. @@ -129,11 +138,11 @@ func calculateSeedSignature(cred auth.Credentials, r *http.Request) (signature s // Verify if signature match. if !compareSignatureV4(newSignature, signV4Values.Signature) { - return "", "", time.Time{}, ErrSignatureDoesNotMatch + return cred, "", "", time.Time{}, ErrSignatureDoesNotMatch } // Return caculated signature. - return newSignature, region, date, ErrNone + return cred, newSignature, region, date, ErrNone } const maxLineLength = 4 * humanize.KiByte // assumed <= bufio.defaultBufSize 4KiB @@ -151,10 +160,7 @@ var errMalformedEncoding = errors.New("malformed chunked encoding") // NewChunkedReader is not needed by normal applications. The http package // automatically decodes chunking when reading response bodies. func newSignV4ChunkedReader(req *http.Request) (io.ReadCloser, APIErrorCode) { - // Access credentials. - cred := globalServerConfig.GetCredential() - - seedSignature, region, seedDate, errCode := calculateSeedSignature(cred, req) + cred, seedSignature, region, seedDate, errCode := calculateSeedSignature(req) if errCode != ErrNone { return nil, errCode } diff --git a/cmd/sts-errors.go b/cmd/sts-errors.go new file mode 100644 index 000000000..dbb85e5fc --- /dev/null +++ b/cmd/sts-errors.go @@ -0,0 +1,119 @@ +/* + * Minio Cloud Storage, (C) 2018 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 ( + "encoding/xml" + "net/http" +) + +// writeSTSErrorRespone writes error headers +func writeSTSErrorResponse(w http.ResponseWriter, errorCode STSErrorCode) { + stsError := getSTSError(errorCode) + // Generate error response. + stsErrorResponse := getSTSErrorResponse(stsError) + encodedErrorResponse := encodeResponse(stsErrorResponse) + writeResponse(w, stsError.HTTPStatusCode, encodedErrorResponse, mimeXML) +} + +// STSError structure +type STSError struct { + Code string + Description string + HTTPStatusCode int +} + +// STSErrorResponse - error response format +type STSErrorResponse struct { + XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ ErrorResponse" json:"-"` + Error struct { + Type string `xml:"Type"` + Code string `xml:"Code"` + Message string `xml:"Message"` + } `xml:"Error"` + RequestID string `xml:"RequestId"` +} + +// STSErrorCode type of error status. +type STSErrorCode int + +// Error codes, non exhaustive list - http://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithSAML.html +const ( + ErrSTSNone STSErrorCode = iota + ErrSTSMissingParameter + ErrSTSInvalidParameterValue + ErrSTSClientGrantsExpiredToken + ErrSTSInvalidClientGrantsToken + ErrSTSMalformedPolicyDocument + ErrSTSNotInitialized + ErrSTSInternalError +) + +// error code to STSError structure, these fields carry respective +// descriptions for all the error responses. +var stsErrCodeResponse = map[STSErrorCode]STSError{ + ErrSTSMissingParameter: { + Code: "MissingParameter", + Description: "A required parameter for the specified action is not supplied.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrSTSInvalidParameterValue: { + Code: "InvalidParameterValue", + Description: "An invalid or out-of-range value was supplied for the input parameter.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrSTSClientGrantsExpiredToken: { + Code: "ExpiredToken", + Description: "The client grants that was passed is expired or is not valid.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrSTSInvalidClientGrantsToken: { + Code: "InvalidClientGrantsToken", + Description: "The client grants token that was passed could not be validated by Minio.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrSTSMalformedPolicyDocument: { + Code: "MalformedPolicyDocument", + Description: "The request was rejected because the policy document was malformed.", + HTTPStatusCode: http.StatusBadRequest, + }, + ErrSTSNotInitialized: { + Code: "STSNotInitialized", + Description: "STS API not initialized, please try again.", + HTTPStatusCode: http.StatusServiceUnavailable, + }, + ErrSTSInternalError: { + Code: "InternalError", + Description: "We encountered an internal error generating credentials, please try again.", + HTTPStatusCode: http.StatusInternalServerError, + }, +} + +// getSTSError provides STS Error for input STS error code. +func getSTSError(code STSErrorCode) STSError { + return stsErrCodeResponse[code] +} + +// getErrorResponse gets in standard error and resource value and +// provides a encodable populated response values +func getSTSErrorResponse(err STSError) STSErrorResponse { + errRsp := STSErrorResponse{} + errRsp.Error.Code = err.Code + errRsp.Error.Message = err.Description + errRsp.RequestID = "3L137" + return errRsp +} diff --git a/cmd/sts-handlers.go b/cmd/sts-handlers.go new file mode 100644 index 000000000..8a6c692e3 --- /dev/null +++ b/cmd/sts-handlers.go @@ -0,0 +1,203 @@ +/* + * Minio Cloud Storage, (C) 2018 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 ( + "bytes" + "encoding/base64" + "encoding/xml" + "net/http" + + "github.com/gorilla/mux" + "github.com/minio/minio/cmd/logger" + "github.com/minio/minio/pkg/auth" + "github.com/minio/minio/pkg/iam/policy" + "github.com/minio/minio/pkg/iam/validator" +) + +const ( + // STS API version. + stsAPIVersion = "2011-06-15" +) + +// stsAPIHandlers implements and provides http handlers for AWS STS API. +type stsAPIHandlers struct{} + +// registerSTSRouter - registers AWS STS compatible APIs. +func registerSTSRouter(router *mux.Router) { + // Initialize STS. + sts := &stsAPIHandlers{} + + // STS Router + stsRouter := router.NewRoute().PathPrefix("/").Subrouter() + + // AssumeRoleWithClientGrants + stsRouter.Methods("POST").HandlerFunc(httpTraceAll(sts.AssumeRoleWithClientGrants)). + Queries("Action", "AssumeRoleWithClientGrants"). + Queries("Token", "{Token:.*}") + +} + +// AssumedRoleUser - The identifiers for the temporary security credentials that +// the operation returns. Please also see https://docs.aws.amazon.com/goto/WebAPI/sts-2011-06-15/AssumedRoleUser +type AssumedRoleUser struct { + // The ARN of the temporary security credentials that are returned from the + // AssumeRole action. For more information about ARNs and how to use them in + // policies, see IAM Identifiers (http://docs.aws.amazon.com/IAM/latest/UserGuide/reference_identifiers.html) + // in Using IAM. + // + // Arn is a required field + Arn string + + // A unique identifier that contains the role ID and the role session name of + // the role that is being assumed. The role ID is generated by AWS when the + // role is created. + // + // AssumedRoleId is a required field + AssumedRoleID string `xml:"AssumeRoleId"` + // contains filtered or unexported fields +} + +// AssumeRoleWithClientGrantsResponse contains the result of successful AssumeRoleWithClientGrants request. +type AssumeRoleWithClientGrantsResponse struct { + XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleWithClientGrantsResponse" json:"-"` + Result ClientGrantsResult `xml:"AssumeRoleWithClientGrantsResult"` + ResponseMetadata struct { + RequestID string `xml:"RequestId,omitempty"` + } `xml:"ResponseMetadata,omitempty"` +} + +// ClientGrantsResult - Contains the response to a successful AssumeRoleWithClientGrants +// request, including temporary credentials that can be used to make Minio API requests. +type ClientGrantsResult struct { + // The identifiers for the temporary security credentials that the operation + // returns. + AssumedRoleUser AssumedRoleUser `xml:",omitempty"` + + // The intended audience (also known as client ID) of the web identity token. + // This is traditionally the client identifier issued to the application that + // requested the client grants. + Audience string `xml:",omitempty"` + + // The temporary security credentials, which include an access key ID, a secret + // access key, and a security (or session) token. + // + // Note: The size of the security token that STS APIs return is not fixed. We + // strongly recommend that you make no assumptions about the maximum size. As + // of this writing, the typical size is less than 4096 bytes, but that can vary. + // Also, future updates to AWS might require larger sizes. + Credentials auth.Credentials `xml:",omitempty"` + + // A percentage value that indicates the size of the policy in packed form. + // The service rejects any policy with a packed size greater than 100 percent, + // which means the policy exceeded the allowed space. + PackedPolicySize int `xml:",omitempty"` + + // The issuing authority of the web identity token presented. For OpenID Connect + // ID tokens, this contains the value of the iss field. For OAuth 2.0 access tokens, + // this contains the value of the ProviderId parameter that was passed in the + // AssumeRoleWithClientGrants request. + Provider string `xml:",omitempty"` + + // The unique user identifier that is returned by the identity provider. + // This identifier is associated with the Token that was submitted + // with the AssumeRoleWithClientGrants call. The identifier is typically unique to + // the user and the application that acquired the ClientGrantsToken (pairwise identifier). + // For OpenID Connect ID tokens, this field contains the value returned by the identity + // provider as the token's sub (Subject) claim. + SubjectFromToken string `xml:",omitempty"` +} + +// AssumeRoleWithClientGrants - implementation of AWS STS extension API supporting +// OAuth2.0 client grants. +// +// Eg:- +// $ curl https://minio:9000/?Action=AssumeRoleWithClientGrants&Token= +func (sts *stsAPIHandlers) AssumeRoleWithClientGrants(w http.ResponseWriter, r *http.Request) { + ctx := newContext(r, w, "AssumeRoleWithClientGrants") + + if globalIAMValidators == nil { + writeSTSErrorResponse(w, ErrSTSNotInitialized) + return + } + + // NOTE: this API only accepts JWT tokens. + v, err := globalIAMValidators.Get("jwt") + if err != nil { + writeSTSErrorResponse(w, ErrSTSInvalidParameterValue) + return + } + + policyStr := r.URL.Query().Get("Policy") + var p *iampolicy.Policy + if policyStr != "" { + var data []byte + data, err = base64.URLEncoding.DecodeString(policyStr) + if err != nil { + writeSTSErrorResponse(w, ErrSTSInvalidParameterValue) + return + } + p, err = iampolicy.ParseConfig(bytes.NewReader(data)) + if err != nil { + writeSTSErrorResponse(w, ErrSTSInvalidParameterValue) + return + } + } + + vars := mux.Vars(r) + m, err := v.Validate(vars["Token"], r.URL.Query().Get("DurationSeconds")) + if err != nil { + switch err { + case validator.ErrTokenExpired: + writeSTSErrorResponse(w, ErrSTSClientGrantsExpiredToken) + case validator.ErrInvalidDuration: + writeSTSErrorResponse(w, ErrSTSInvalidParameterValue) + default: + logger.LogIf(ctx, err) + writeSTSErrorResponse(w, ErrSTSInvalidParameterValue) + } + return + } + + secret := globalServerConfig.GetCredential().SecretKey + cred, err := auth.GetNewCredentialsWithMetadata(m, secret) + if err != nil { + logger.LogIf(ctx, err) + writeSTSErrorResponse(w, ErrSTSInternalError) + return + } + + // Set the newly generated credentials. + if err = globalIAMSys.SetTempUser(cred.AccessKey, cred); err != nil { + logger.LogIf(ctx, err) + writeSTSErrorResponse(w, ErrSTSInternalError) + return + } + if p != nil { + if err = globalIAMSys.SetPolicy(cred.AccessKey, *p); err != nil { + logger.LogIf(ctx, err) + writeSTSErrorResponse(w, ErrSTSInternalError) + return + } + } + + encodedSuccessResponse := encodeResponse(&AssumeRoleWithClientGrantsResponse{ + Result: ClientGrantsResult{Credentials: cred}, + }) + + writeSuccessResponseXML(w, encodedSuccessResponse) +} diff --git a/cmd/test-utils_test.go b/cmd/test-utils_test.go index 821a22043..1d3ec4fed 100644 --- a/cmd/test-utils_test.go +++ b/cmd/test-utils_test.go @@ -354,10 +354,16 @@ func UnstartedTestServer(t TestErrHandler, instanceType string) TestServer { globalMinioPort = port globalMinioAddr = getEndpointsLocalAddr(testServer.Disks) - globalNotificationSys = NewNotificationSys(globalServerConfig, testServer.Disks) + globalConfigSys = NewConfigSys() + + globalIAMSys = NewIAMSys() + globalIAMSys.Init(objLayer) - // Create new policy system. globalPolicySys = NewPolicySys() + globalPolicySys.Init(objLayer) + + globalNotificationSys = NewNotificationSys(globalServerConfig, testServer.Disks) + globalNotificationSys.Init(objLayer) return testServer } @@ -495,7 +501,7 @@ func resetTestGlobals() { // Configure the server for the test run. func newTestConfig(bucketLocation string, obj ObjectLayer) (err error) { // Initialize server config. - if err = newConfig(obj); err != nil { + if err = newSrvConfig(obj); err != nil { return err } @@ -1620,11 +1626,13 @@ func newTestObjectLayer(endpoints EndpointList) (newObject ObjectLayer, err erro return xl.storageDisks } - // Create new notification system. - globalNotificationSys = NewNotificationSys(globalServerConfig, endpoints) + globalConfigSys = NewConfigSys() + + globalIAMSys = NewIAMSys() + globalIAMSys.Init(xl) - // Create new policy system. globalPolicySys = NewPolicySys() + globalNotificationSys = NewNotificationSys(globalServerConfig, endpoints) return xl, nil } diff --git a/cmd/web-handlers.go b/cmd/web-handlers.go index 00e998c17..2f1433697 100644 --- a/cmd/web-handlers.go +++ b/cmd/web-handlers.go @@ -47,6 +47,7 @@ import ( "github.com/minio/minio/pkg/event" "github.com/minio/minio/pkg/handlers" "github.com/minio/minio/pkg/hash" + "github.com/minio/minio/pkg/iam/policy" "github.com/minio/minio/pkg/ioutil" "github.com/minio/minio/pkg/policy" ) @@ -236,10 +237,11 @@ func (web *webAPIHandlers) ListBuckets(r *http.Request, args *WebGenericArgs, re if web.CacheAPI() != nil { listBuckets = web.CacheAPI().ListBuckets } - authErr := webRequestAuthenticate(r) - if authErr != nil { + + if _, _, authErr := webRequestAuthenticate(r); authErr != nil { return toJSONError(authErr) } + // If etcd, dns federation configured list buckets from etcd. if globalDNSConfig != nil { dnsBuckets, err := globalDNSConfig.List() @@ -305,32 +307,66 @@ func (web *webAPIHandlers) ListObjects(r *http.Request, args *ListObjectsArgs, r if objectAPI == nil { return toJSONError(errServerNotInitialized) } + listObjects := objectAPI.ListObjects if web.CacheAPI() != nil { listObjects = web.CacheAPI().ListObjects } - // Check if anonymous (non-owner) has access to download objects. - readable := globalPolicySys.IsAllowed(policy.Args{ - Action: policy.GetObjectAction, - BucketName: args.BucketName, - ConditionValues: getConditionValues(r, ""), - IsOwner: false, - ObjectName: args.Prefix + "/", - }) - // Check if anonymous (non-owner) has access to upload objects. - writable := globalPolicySys.IsAllowed(policy.Args{ - Action: policy.PutObjectAction, - BucketName: args.BucketName, - ConditionValues: getConditionValues(r, ""), - IsOwner: false, - ObjectName: args.Prefix + "/", - }) + claims, owner, authErr := webRequestAuthenticate(r) + if authErr != nil { + if authErr == errNoAuthToken { + // Check if anonymous (non-owner) has access to download objects. + readable := globalPolicySys.IsAllowed(policy.Args{ + Action: policy.GetObjectAction, + BucketName: args.BucketName, + ConditionValues: getConditionValues(r, ""), + IsOwner: false, + ObjectName: args.Prefix + "/", + }) + + // Check if anonymous (non-owner) has access to upload objects. + writable := globalPolicySys.IsAllowed(policy.Args{ + Action: policy.PutObjectAction, + BucketName: args.BucketName, + ConditionValues: getConditionValues(r, ""), + IsOwner: false, + ObjectName: args.Prefix + "/", + }) - if authErr := webRequestAuthenticate(r); authErr != nil { - if authErr == errAuthentication { + reply.Writable = writable + if !readable { + // Error out if anonymous user (non-owner) has no access to download or upload objects + if !writable { + return errAuthentication + } + // return empty object list if access is write only + return nil + } + } else { return toJSONError(authErr) } + } + + // For authenticated users apply IAM policy. + if authErr == nil { + readable := globalIAMSys.IsAllowed(iampolicy.Args{ + AccountName: claims.Subject, + Action: iampolicy.Action(policy.GetObjectAction), + BucketName: args.BucketName, + ConditionValues: getConditionValues(r, ""), + IsOwner: owner, + ObjectName: args.Prefix + "/", + }) + + writable := globalIAMSys.IsAllowed(iampolicy.Args{ + AccountName: claims.Subject, + Action: iampolicy.Action(policy.PutObjectAction), + BucketName: args.BucketName, + ConditionValues: getConditionValues(r, ""), + IsOwner: owner, + ObjectName: args.Prefix + "/", + }) reply.Writable = writable if !readable { @@ -341,7 +377,6 @@ func (web *webAPIHandlers) ListObjects(r *http.Request, args *ListObjectsArgs, r // return empty object list if access is write only return nil } - } lo, err := listObjects(context.Background(), args.BucketName, args.Prefix, args.Marker, slashSeparator, 1000) @@ -619,18 +654,34 @@ func (web *webAPIHandlers) Upload(w http.ResponseWriter, r *http.Request) { bucket := vars["bucket"] object := vars["object"] - if authErr := webRequestAuthenticate(r); authErr != nil { - if authErr == errAuthentication { - writeWebErrorResponse(w, errAuthentication) + claims, owner, authErr := webRequestAuthenticate(r) + if authErr != nil { + if authErr == errNoAuthToken { + // Check if anonymous (non-owner) has access to upload objects. + if !globalPolicySys.IsAllowed(policy.Args{ + Action: policy.PutObjectAction, + BucketName: bucket, + ConditionValues: getConditionValues(r, ""), + IsOwner: false, + ObjectName: object, + }) { + writeWebErrorResponse(w, errAuthentication) + return + } + } else { + writeWebErrorResponse(w, authErr) return } + } - // Check if anonymous (non-owner) has access to upload objects. - if !globalPolicySys.IsAllowed(policy.Args{ - Action: policy.PutObjectAction, + // For authenticated users apply IAM policy. + if authErr == nil { + if !globalIAMSys.IsAllowed(iampolicy.Args{ + AccountName: claims.Subject, + Action: iampolicy.Action(policy.PutObjectAction), BucketName: bucket, ConditionValues: getConditionValues(r, ""), - IsOwner: false, + IsOwner: owner, ObjectName: object, }) { writeWebErrorResponse(w, errAuthentication) @@ -734,13 +785,34 @@ func (web *webAPIHandlers) Download(w http.ResponseWriter, r *http.Request) { object := vars["object"] token := r.URL.Query().Get("token") - if !isAuthTokenValid(token) { - // Check if anonymous (non-owner) has access to download objects. - if !globalPolicySys.IsAllowed(policy.Args{ - Action: policy.GetObjectAction, + claims, owner, authErr := webTokenAuthenticate(token) + if authErr != nil { + if authErr == errNoAuthToken { + // Check if anonymous (non-owner) has access to download objects. + if !globalPolicySys.IsAllowed(policy.Args{ + Action: policy.GetObjectAction, + BucketName: bucket, + ConditionValues: getConditionValues(r, ""), + IsOwner: false, + ObjectName: object, + }) { + writeWebErrorResponse(w, errAuthentication) + return + } + } else { + writeWebErrorResponse(w, authErr) + return + } + } + + // For authenticated users apply IAM policy. + if authErr == nil { + if !globalIAMSys.IsAllowed(iampolicy.Args{ + AccountName: claims.Subject, + Action: iampolicy.Action(policy.GetObjectAction), BucketName: bucket, ConditionValues: getConditionValues(r, ""), - IsOwner: false, + IsOwner: owner, ObjectName: object, }) { writeWebErrorResponse(w, errAuthentication) @@ -886,14 +958,7 @@ func (web *webAPIHandlers) DownloadZip(w http.ResponseWriter, r *http.Request) { writeWebErrorResponse(w, errServerNotInitialized) return } - getObject := objectAPI.GetObject - if web.CacheAPI() != nil { - getObject = web.CacheAPI().GetObject - } - listObjects := objectAPI.ListObjects - if web.CacheAPI() != nil { - listObjects = web.CacheAPI().ListObjects - } + // Auth is done after reading the body to accommodate for anonymous requests // when bucket policy is enabled. var args DownloadZipArgs @@ -905,14 +970,37 @@ func (web *webAPIHandlers) DownloadZip(w http.ResponseWriter, r *http.Request) { } token := r.URL.Query().Get("token") - if !isAuthTokenValid(token) { + claims, owner, authErr := webTokenAuthenticate(token) + if authErr != nil { + if authErr == errNoAuthToken { + for _, object := range args.Objects { + // Check if anonymous (non-owner) has access to download objects. + if !globalPolicySys.IsAllowed(policy.Args{ + Action: policy.GetObjectAction, + BucketName: args.BucketName, + ConditionValues: getConditionValues(r, ""), + IsOwner: false, + ObjectName: pathJoin(args.Prefix, object), + }) { + writeWebErrorResponse(w, errAuthentication) + return + } + } + } else { + writeWebErrorResponse(w, authErr) + return + } + } + + // For authenticated users apply IAM policy. + if authErr == nil { for _, object := range args.Objects { - // Check if anonymous (non-owner) has access to download objects. - if !globalPolicySys.IsAllowed(policy.Args{ - Action: policy.GetObjectAction, + if !globalIAMSys.IsAllowed(iampolicy.Args{ + AccountName: claims.Subject, + Action: iampolicy.Action(policy.GetObjectAction), BucketName: args.BucketName, ConditionValues: getConditionValues(r, ""), - IsOwner: false, + IsOwner: owner, ObjectName: pathJoin(args.Prefix, object), }) { writeWebErrorResponse(w, errAuthentication) @@ -921,8 +1009,18 @@ func (web *webAPIHandlers) DownloadZip(w http.ResponseWriter, r *http.Request) { } } + getObject := objectAPI.GetObject + if web.CacheAPI() != nil { + getObject = web.CacheAPI().GetObject + } + listObjects := objectAPI.ListObjects + if web.CacheAPI() != nil { + listObjects = web.CacheAPI().ListObjects + } + archive := zip.NewWriter(w) defer archive.Close() + getObjectInfo := objectAPI.GetObjectInfo if web.CacheAPI() != nil { getObjectInfo = web.CacheAPI().GetObjectInfo diff --git a/cmd/web-handlers_test.go b/cmd/web-handlers_test.go index 395f0b2d3..c9bc657a4 100644 --- a/cmd/web-handlers_test.go +++ b/cmd/web-handlers_test.go @@ -1294,7 +1294,7 @@ func testWebGetBucketPolicyHandler(obj ObjectLayer, instanceType string, t TestE }, } - if err = savePolicyConfig(obj, bucketName, bucketPolicy); err != nil { + if err = savePolicyConfig(context.Background(), obj, bucketName, bucketPolicy); err != nil { t.Fatal("Unexpected error: ", err) } @@ -1394,7 +1394,7 @@ func testWebListAllBucketPoliciesHandler(obj ObjectLayer, instanceType string, t }, } - if err = savePolicyConfig(obj, bucketName, bucketPolicy); err != nil { + if err = savePolicyConfig(context.Background(), obj, bucketName, bucketPolicy); err != nil { t.Fatal("Unexpected error: ", err) } diff --git a/cmd/xl-sets.go b/cmd/xl-sets.go index a1eed3fd2..308661c12 100644 --- a/cmd/xl-sets.go +++ b/cmd/xl-sets.go @@ -499,7 +499,7 @@ func (s *xlSets) ListObjectsV2(ctx context.Context, bucket, prefix, continuation // SetBucketPolicy persist the new policy on the bucket. func (s *xlSets) SetBucketPolicy(ctx context.Context, bucket string, policy *policy.Policy) error { - return savePolicyConfig(s, bucket, policy) + return savePolicyConfig(ctx, s, bucket, policy) } // GetBucketPolicy will return a policy on a bucket diff --git a/cmd/xl-v1-bucket.go b/cmd/xl-v1-bucket.go index 1200c6ba4..20a87acbc 100644 --- a/cmd/xl-v1-bucket.go +++ b/cmd/xl-v1-bucket.go @@ -277,7 +277,7 @@ func (xl xlObjects) DeleteBucket(ctx context.Context, bucket string) error { // SetBucketPolicy sets policy on bucket func (xl xlObjects) SetBucketPolicy(ctx context.Context, bucket string, policy *policy.Policy) error { - return savePolicyConfig(xl, bucket, policy) + return savePolicyConfig(ctx, xl, bucket, policy) } // GetBucketPolicy will get policy on bucket diff --git a/docs/config/config.sample.json b/docs/config/config.sample.json index 1f0242c5b..fc80a3203 100644 --- a/docs/config/config.sample.json +++ b/docs/config/config.sample.json @@ -1,5 +1,5 @@ { - "version": "29", + "version": "30", "credential": { "accessKey": "USWUXHGYZQYFYFFIT3RE", "secretKey": "MOJRH0mkL1IPauahWITSVvyDrQbEEIwljvmxdq03" @@ -132,5 +132,27 @@ "password": "" } } + }, + "logger": { + "console": { + "enabled": true + }, + "http": { + "1": { + "enabled": false, + "endpoint": "" + } + } + }, + "openid": { + "jwks": { + "url": "" + } + }, + "policy": { + "opa": { + "url": "", + "authToken": "" + } } } diff --git a/docs/multi-user/README.md b/docs/multi-user/README.md new file mode 100644 index 000000000..365480d6c --- /dev/null +++ b/docs/multi-user/README.md @@ -0,0 +1,46 @@ +# Minio multi-user Quickstart Guide [![Slack](https://slack.minio.io/slack?type=svg)](https://slack.minio.io) +This document explains how to add, revoke users. Multi-user as name implies means Minio supports long term users other than default credentials, each of these users can be configured to deny or allow access to buckets, resources. + +## Get started +In this document we will explain in detail on how to configure multiple users. + +### 1. Prerequisites +- Install mc - [Minio Client Quickstart Guide](https://docs.minio.io/docs/minio-client-quickstart-guide.html) +- Install Minio - [Minio Quickstart Guide](https://docs.minio.io/docs/minio-quickstart-guide) + +### 2. Create a new user and policy +Create a new user `newuser` on Minio use `mc admin users`, with a `newuser.json`. +``` +mc admin users add myminio newuser newuser123 /tmp/newuser.json +``` + +An example user policy, enables `newuser` to download all objects in my-bucketname. +```json +{ + "Version": "2012-10-17", + "Statement": [ + { + "Action": [ + "s3:GetObject" + ], + "Effect": "Allow", + "Resource": [ + "arn:aws:s3:::my-bucketname/*" + ], + "Sid": "" + } + ] +} +``` + +### 3. Revoke user +Temporarily revoke access for `newuser`. +``` +mc admin users revoke myminio newuser +``` + +### 4. Remove user +Remove the user `newuser`. +``` +mc admin users remove myminio newuser +``` diff --git a/docs/sts/README.md b/docs/sts/README.md new file mode 100644 index 000000000..342c59b4b --- /dev/null +++ b/docs/sts/README.md @@ -0,0 +1,51 @@ +# Minio STS Quickstart Guide [![Slack](https://slack.minio.io/slack?type=svg)](https://slack.minio.io) +The Minio Security Token Service (STS) is an endpoint service that enables clients to request temporary credentials for Minio resources. Temporary credentials work almost identically to default admin credentials, with some differences: + +- Temporary credentials are short-term, as the name implies. They can be configured to last for anywhere from a few minutes to several hours. After the credentials expire, Minio no longer recognizes them or allows any kind of access from API requests made with them. +- Temporary credentials do not need to be stored with the application but are generated dynamically and provided to the application when requested. When (or even before) the temporary credentials expire, the application can request new credentials. + +Following are advantages for using temporary credentials: + +- No need embed long-term credentials with an application. +- No need to provide access to buckets and objects without having to define static credentials. +- Temporary credentials have a limited lifetime, no need to rotate them or explicitly revoke them when they're no longer needed. After temporary credentials expire, they cannot be reused. + +## Identity Federation +[**Client grants**](./client-grants.md) - Let applications request `client_grants` using any well-known third party identity provider such as KeyCloak, WSO2. This is known as the client grants approach to temporary access. Using this approach helps clients keep Minio credentials to be secured. Minio STS client grants supports WSO2, Keycloak. + +## Get started +In this document we will explain in detail on how to configure all the prerequisites, primarily WSO2, OPA (open policy agent). + +### 1. Prerequisites +- [Configuring wso2](./wso2.md) +- [Configuring opa](./opa.md) + +### 2. Setup Minio with WSO2, OPA +Make sure we have followed the previous step and configured each software independently, once done we can now proceed to use Minio STS API and Minio server to use these credentials to perform object API operations. + +``` +export MINIO_ACCESS_KEY=minio +export MINIO_SECRET_KEY=minio123 +export MINIO_IAM_JWKS_URL=https://localhost:9443/oauth2/jwks +export MINIO_IAM_OPA_URL=http://localhost:8181/v1/data/httpapi/authz +minio server /mnt/data +``` + +### 3. Test using full-example.go +On another terminal run `full-example.go` a sample client application which obtains JWT access tokens from an identity provider, in our case its WSO2. Uses the returned access token response to get new temporary credentials from the Minio server using the STS API call `AssumeRoleWithClientGrants`. + +``` +go run full-example.go -cid PoEgXP6uVO45IsENRngDXj5Au5Ya -csec eKsw6z8CtOJVBtrOWvhRWL4TUCga + +##### Credentials +{ + "accessKey": "NUIBORZYTV2HG2BMRSXR", + "secretKey": "qQlP5O7CFPc5m5IXf1vYhuVTFj7BRVJqh0FqZ86S", + "expiration": "2018-08-21T17:10:29-07:00", + "sessionToken": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiJOVUlCT1JaWVRWMkhHMkJNUlNYUiIsImF1ZCI6IlBvRWdYUDZ1Vk80NUlzRU5SbmdEWGo1QXU1WWEiLCJhenAiOiJQb0VnWFA2dVZPNDVJc0VOUm5nRFhqNUF1NVlhIiwiZXhwIjoxNTM0ODk2NjI5LCJpYXQiOjE1MzQ4OTMwMjksImlzcyI6Imh0dHBzOi8vbG9jYWxob3N0Ojk0NDMvb2F1dGgyL3Rva2VuIiwianRpIjoiNjY2OTZjZTctN2U1Ny00ZjU5LWI0MWQtM2E1YTMzZGZiNjA4In0.eJONnVaSVHypiXKEARSMnSKgr-2mlC2Sr4fEGJitLcJF_at3LeNdTHv0_oHsv6ZZA3zueVGgFlVXMlREgr9LXA" +} +``` + +## Explore Further +- [Minio STS Quickstart Guide](https://docs.minio.io/docs/minio-sts-quickstart-guide) +- [The Minio documentation website](https://docs.minio.io) diff --git a/docs/sts/client-grants.md b/docs/sts/client-grants.md new file mode 100644 index 000000000..ec3e0f32f --- /dev/null +++ b/docs/sts/client-grants.md @@ -0,0 +1,71 @@ +## AssumeRoleWithClientGrants [![Slack](https://slack.minio.io/slack?type=svg)](https://slack.minio.io) +Returns a set of temporary security credentials for applications/clients who have been authenticated through client grants provided by identity provider. Example providers include WSO2, KeyCloak etc. + +Calling AssumeRoleWithClientGrants does not require the use of Minio default credentials. Therefore, client application can be distributed that requests temporary security credentials without including Minio default credentials. Instead, the identity of the caller is validated by using a JWT access token from the identity provider. The temporary security credentials returned by this API consist of an access key, a secret key, and a security token. Applications can use these temporary security credentials to sign calls to Minio API operations. + +By default, the temporary security credentials created by AssumeRoleWithClientGrants 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 to 12 hours. + +### Request Parameters +#### DurationSeconds +The duration, in seconds. The value can range from 900 seconds (15 minutes) up to the 12 hours. If value is higher than this setting, then operation fails. By default, the value is set to 3600 seconds. + +| Params | Value | +| :-- | :-- | +| *Type* | *Integer* | +| *Valid Range* | *Minimum value of 900. Maximum value of 43200.* | +| *Required* | *No* | + +#### Token +The OAuth 2.0 access token that is provided by the identity provider. Application must get this token by authenticating the application using client grants before the application makes an AssumeRoleWithClientGrants call. + +| Params | Value | +| :-- | :-- | +| *Type* | *String* | +| *Length Constraints* | *Minimum length of 4. Maximum length of 2048.* | +| *Required* | *Yes* | + +#### Response Elements +XML response for this API is similar to [AWS STS AssumeRoleWithWebIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html#API_AssumeRoleWithWebIdentity_ResponseElements) + +#### Errors +XML error response for this API is similar to [AWS STS AssumeRoleWithWebIdentity](https://docs.aws.amazon.com/STS/latest/APIReference/API_AssumeRoleWithWebIdentity.html#API_AssumeRoleWithWebIdentity_Errors) + +#### Testing +``` +$ export MINIO_ACCESS_KEY=minio +$ export MINIO_SECRET_KEY=minio123 +$ export MINIO_IAM_JWKS_URL=https://localhost:9443/oauth2/jwks +$ export MINIO_IAM_OPA_URL=http://localhost:8181/v1/data/httpapi/authz +$ minio server /mnt/export + +$ mc admin config get myminio +... +{ + "openid": { + "jwks": { + "url": "https://localhost:9443/oauth2/jwks" + } + } + "policy": { + "opa": { + "url": "http://localhost:8181/v1/data/httpapi/authz", + "authToken": "" + } + } +} +``` + +Testing with an example +> Obtaining client ID and secrets follow [WSO2 configuring documentation](./wso2.md) + +``` +go run full-example.go -cid PoEgXP6uVO45IsENRngDXj5Au5Ya -csec eKsw6z8CtOJVBtrOWvhRWL4TUCga + +##### Credentials +{ + "accessKey": "NUIBORZYTV2HG2BMRSXR", + "secretKey": "qQlP5O7CFPc5m5IXf1vYhuVTFj7BRVJqh0FqZ86S", + "expiration": "2018-08-21T17:10:29-07:00", + "sessionToken": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiJOVUlCT1JaWVRWMkhHMkJNUlNYUiIsImF1ZCI6IlBvRWdYUDZ1Vk80NUlzRU5SbmdEWGo1QXU1WWEiLCJhenAiOiJQb0VnWFA2dVZPNDVJc0VOUm5nRFhqNUF1NVlhIiwiZXhwIjoxNTM0ODk2NjI5LCJpYXQiOjE1MzQ4OTMwMjksImlzcyI6Imh0dHBzOi8vbG9jYWxob3N0Ojk0NDMvb2F1dGgyL3Rva2VuIiwianRpIjoiNjY2OTZjZTctN2U1Ny00ZjU5LWI0MWQtM2E1YTMzZGZiNjA4In0.eJONnVaSVHypiXKEARSMnSKgr-2mlC2Sr4fEGJitLcJF_at3LeNdTHv0_oHsv6ZZA3zueVGgFlVXMlREgr9LXA" +} +``` diff --git a/docs/sts/docker-compose.yml b/docs/sts/docker-compose.yml new file mode 100644 index 000000000..379e12f14 --- /dev/null +++ b/docs/sts/docker-compose.yml @@ -0,0 +1,17 @@ +version: '2' +services: + opa: + image: openpolicyagent/opa:0.9.1 + ports: + - 8181:8181 + command: + - "run" + - "--server" + - "--log-level=debug" + api_server: + image: openpolicyagent/demo-restful-api:0.2 + ports: + - 5000:5000 + environment: + - OPA_ADDR=http://opa:8181 + - POLICY_PATH=/v1/data/httpapi/authz diff --git a/docs/sts/full-example.go b/docs/sts/full-example.go new file mode 100644 index 000000000..c25a76f4b --- /dev/null +++ b/docs/sts/full-example.go @@ -0,0 +1,180 @@ +/* + * Minio Cloud Storage, (C) 2018 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 ( + "bytes" + "crypto/tls" + "encoding/json" + "encoding/xml" + "flag" + "fmt" + "log" + "net/http" + "net/url" + "strings" + + minio "github.com/minio/minio-go" + "github.com/minio/minio-go/pkg/credentials" + "github.com/minio/minio/pkg/auth" +) + +// AssumedRoleUser - The identifiers for the temporary security credentials that +// the operation returns. Please also see https://docs.aws.amazon.com/goto/WebAPI/sts-2011-06-15/AssumedRoleUser +type AssumedRoleUser struct { + Arn string + AssumedRoleID string `xml:"AssumeRoleId"` + // contains filtered or unexported fields +} + +// AssumeRoleWithClientGrantsResponse contains the result of successful AssumeRoleWithClientGrants request. +type AssumeRoleWithClientGrantsResponse struct { + XMLName xml.Name `xml:"https://sts.amazonaws.com/doc/2011-06-15/ AssumeRoleWithClientGrantsResponse" json:"-"` + Result ClientGrantsResult `xml:"AssumeRoleWithClientGrantsResult"` + ResponseMetadata struct { + RequestID string `xml:"RequestId,omitempty"` + } `xml:"ResponseMetadata,omitempty"` +} + +// ClientGrantsResult - Contains the response to a successful AssumeRoleWithClientGrants +// request, including temporary credentials that can be used to make Minio API requests. +type ClientGrantsResult struct { + AssumedRoleUser AssumedRoleUser `xml:",omitempty"` + Audience string `xml:",omitempty"` + Credentials auth.Credentials `xml:",omitempty"` + PackedPolicySize int `xml:",omitempty"` + Provider string `xml:",omitempty"` + SubjectFromClientGrantsToken string `xml:",omitempty"` +} + +// JWTToken - parses the output from IDP access token. +type JWTToken struct { + AccessToken string `json:"access_token"` + Expiry int `json:"expires_in"` +} + +var ( + stsEndpoint string + idpEndpoint string + clientID string + clientSecret string +) + +func init() { + flag.StringVar(&stsEndpoint, "sts-ep", "http://localhost:9000", "STS endpoint") + flag.StringVar(&idpEndpoint, "idp-ep", "https://localhost:9443/oauth2/token", "IDP endpoint") + flag.StringVar(&clientID, "cid", "", "Client ID") + flag.StringVar(&clientSecret, "csec", "", "Client secret") +} + +func main() { + flag.Parse() + if clientID == "" || clientSecret == "" { + flag.PrintDefaults() + return + } + + data := url.Values{} + data.Set("grant_type", "client_credentials") + req, err := http.NewRequest(http.MethodPost, idpEndpoint, strings.NewReader(data.Encode())) + if err != nil { + log.Fatal(err) + } + req.Header.Set("Content-Type", "application/x-www-form-urlencoded") + req.SetBasicAuth(clientID, clientSecret) + t := &http.Transport{ + TLSClientConfig: &tls.Config{ + InsecureSkipVerify: true, + }, + } + hclient := http.Client{ + Transport: t, + } + resp, err := hclient.Do(req) + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + log.Fatal(resp.Status) + } + + var idpToken JWTToken + if err = json.NewDecoder(resp.Body).Decode(&idpToken); err != nil { + log.Fatal(err) + } + + v := url.Values{} + v.Set("Action", "AssumeRoleWithClientGrants") + v.Set("Token", idpToken.AccessToken) + v.Set("DurationSeconds", fmt.Sprintf("%d", idpToken.Expiry)) + + u, err := url.Parse(stsEndpoint) + if err != nil { + log.Fatal(err) + } + u.RawQuery = v.Encode() + + req, err = http.NewRequest("POST", u.String(), nil) + if err != nil { + log.Fatal(err) + } + resp, err = http.DefaultClient.Do(req) + if err != nil { + log.Fatal(err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + log.Fatal(resp.Status) + } + + a := AssumeRoleWithClientGrantsResponse{} + if err = xml.NewDecoder(resp.Body).Decode(&a); err != nil { + log.Fatal(err) + } + + fmt.Println("##### Credentials") + c, err := json.MarshalIndent(a.Result.Credentials, "", "\t") + if err != nil { + log.Fatal(err) + } + fmt.Println(string(c)) + + // Uncommend this to use Minio API operations by initializin minio + // client with obtained credentials. + + opts := &minio.Options{ + Creds: credentials.NewStaticV4(a.Result.Credentials.AccessKey, + a.Result.Credentials.SecretKey, + a.Result.Credentials.SessionToken, + ), + BucketLookup: minio.BucketLookupAuto, + } + + clnt, err := minio.NewWithOptions(u.Host, opts) + if err != nil { + log.Fatal(err) + } + + d := bytes.NewReader([]byte("Hello, World")) + n, err := clnt.PutObject("my-bucketname", "my-objectname", d, d.Size(), minio.PutObjectOptions{}) + if err != nil { + log.Fatalln(err) + } + + log.Println("Uploaded", "my-objectname", " of size: ", n, "Successfully.") +} diff --git a/docs/sts/opa.md b/docs/sts/opa.md new file mode 100644 index 000000000..a26693f3b --- /dev/null +++ b/docs/sts/opa.md @@ -0,0 +1,85 @@ +# OPA Quickstart Guide [![Slack](https://slack.minio.io/slack?type=svg)](https://slack.minio.io) +OPA is a lightweight general-purpose policy engine that can be co-located with Minio server, in this document we talk about how to use OPA HTTP API to authorize Minio STS credentials. + +## Get started +### 1. Prerequisites +- Docker 18.03 or above, refer here for [installation](https://docs.docker.com/install/). +- Docker compose 1.20 or above, refere here for [installation](https://docs.docker.com/compose/install/#prerequisites). + +### 2. Start OPA +First, create a `docker-compose.yml` file that runs OPA and the demo web server. +``` +cat >docker-compose.yml < putobject.rego </repository/conf/identity/identity.xml` file and uncomment the following entry under `` element. +``` +org.wso2.carbon.identity.oauth2.token.JWTTokenIssuer +``` +- Restart the server. +- Configure an [OAuth service provider](https://docs.wso2.com/display/IS540/Adding+and+Configuring+a+Service+Provider). +- Initiate an access token request to the WSO2 Identity Server, over a known [grant type](https://docs.wso2.com/display/IS540/OAuth+2.0+Grant+Types). For example, the following cURL command illustrates the syntax of an access token request that can be initiated over the [Client Credentials Grant](https://docs.wso2.com/display/IS540/Client+Credentials+Grant) grant type. + - Navigate to service provider section, expand Inbound Authentication Configurations and expand OAuth/OpenID Connect Configuration. + - Copy the OAuth Client Key as the value for ``. + - Copy the OAuth Client Secret as the value for ``. + - By default, `` is localhost. However, if using a public IP, the respective IP address or domain needs to be specified. + - By default, `` has been set to 9443. However, if the port offset has been incremented by n, the default port value needs to be incremented by n. + +Request +``` +curl -u : -k -d "grant_type=client_credentials" -H "Content-Type:application/x-www-form-urlencoded" https://:/oauth2/token +``` + +Example: +``` +curl -u PoEgXP6uVO45IsENRngDXj5Au5Ya:eKsw6z8CtOJVBtrOWvhRWL4TUCga -k -d "grant_type=client_credentials" -H "Content-Type:application/x-www-form-urlencoded" https://localhost:9443/oauth2/token +``` + +In response, the self-contained JWT access token will be returned as shown below. +``` +{ + "access_token": "eyJ4NXQiOiJOVEF4Wm1NeE5ETXlaRGczTVRVMVpHTTBNekV6T0RKaFpXSTRORE5sWkRVMU9HRmtOakZpTVEiLCJraWQiOiJOVEF4Wm1NeE5ETXlaRGczTVRVMVpHTTBNekV6T0RKaFpXSTRORE5sWkRVMU9HRmtOakZpTVEiLCJhbGciOiJSUzI1NiJ9.eyJhdWQiOiJQb0VnWFA2dVZPNDVJc0VOUm5nRFhqNUF1NVlhIiwiYXpwIjoiUG9FZ1hQNnVWTzQ1SXNFTlJuZ0RYajVBdTVZYSIsImlzcyI6Imh0dHBzOlwvXC9sb2NhbGhvc3Q6OTQ0M1wvb2F1dGgyXC90b2tlbiIsImV4cCI6MTUzNDg5MTc3OCwiaWF0IjoxNTM0ODg4MTc4LCJqdGkiOiIxODQ0MzI5Yy1kNjVhLTQ4YTMtODIyOC05ZGY3M2ZlODNkNTYifQ.ELZ8ujk2Xp9xTGgMqnCa5ehuimaAPXWlSCW5QeBbTJIT4M5OB_2XEVIV6p89kftjUdKu50oiYe4SbfrxmLm6NGSGd2qxkjzJK3SRKqsrmVWEn19juj8fz1neKtUdXVHuSZu6ws_bMDy4f_9hN2Jv9dFnkoyeNT54r4jSTJ4A2FzN2rkiURheVVsc8qlm8O7g64Az-5h4UGryyXU4zsnjDCBKYk9jdbEpcUskrFMYhuUlj1RWSASiGhHHHDU5dTRqHkVLIItfG48k_fb-ehU60T7EFWH1JBdNjOxM9oN_yb0hGwOjLUyCUJO_Y7xcd5F4dZzrBg8LffFmvJ09wzHNtQ", + "token_type": "Bearer", + "expires_in": 3600 +} +``` + +### 4. JWT Claims +The access token received is a signed JSON Web Token (JWT). Use a JWT decoder to decode the access token to access the payload of the token that includes following JWT claims: + +|Claim Name|Type|Claim Value| +|:--:|:--:|:--:| +|iss| string | The issuer of the JWT. The '> Identity Provider Entity Id ' value of the OAuth2/OpenID Connect Inbound Authentication configuration of the Resident Identity Provider is returned here. | +|aud| string array | The token audience list. The client identifier of the OAuth clients that the JWT is intended for, is sent herewith. | +|azp| string | The authorized party for which the token is issued to. The client identifier of the OAuth client that the token is issued for, is sent herewith. | +|iat| integer | The token issue time. | +|exp| integer | The token expiration time. | +|jti| string | Unique identifier for the JWT token. | + +Using the above `access_token` we can perform an STS request to Minio to get temporary credentials for Minio API operations. Minio STS API uses [JSON Web Key Set Endpoint](https://docs.wso2.com/display/IS541/JSON+Web+Key+Set+Endpoint) to validate if JWT is valid and is properly signed. + +### 5. Setup Minio with JWKS URL +Minio server expects environment variable for JWKS url as `MINIO_IAM_JWKS_URL`, this environment variable takes a single entry. +``` +export MINIO_IAM_JWKS_URL=https://localhost:9443/oauth2/jwks +minio server /mnt/data +``` + +Assuming that Minio server is configured to support STS API by following the doc [Minio STS Quickstart Guide](https://docs.minio.io/docs/minio-sts-quickstart-guide), execute the following command to temporary credentials from Minio server. +``` +go run full-example.go -cid PoEgXP6uVO45IsENRngDXj5Au5Ya -csec eKsw6z8CtOJVBtrOWvhRWL4TUCga + +##### Credentials +{ + "accessKey": "IRBLVDGN5QGMDCMO1X8V", + "secretKey": "KzS3UZKE7xqNdtRbKyfcWgxBS6P1G4kwZn4DXKuY", + "expiration": "2018-08-21T15:49:38-07:00", + "sessionToken": "eyJhbGciOiJIUzUxMiIsInR5cCI6IkpXVCJ9.eyJhY2Nlc3NLZXkiOiJJUkJMVkRHTjVRR01EQ01PMVg4ViIsImF1ZCI6IlBvRWdYUDZ1Vk80NUlzRU5SbmdEWGo1QXU1WWEiLCJhenAiOiJQb0VnWFA2dVZPNDVJc0VOUm5nRFhqNUF1NVlhIiwiZXhwIjoxNTM0ODkxNzc4LCJpYXQiOjE1MzQ4ODgxNzgsImlzcyI6Imh0dHBzOi8vbG9jYWxob3N0Ojk0NDMvb2F1dGgyL3Rva2VuIiwianRpIjoiMTg0NDMyOWMtZDY1YS00OGEzLTgyMjgtOWRmNzNmZTgzZDU2In0.4rKsZ8VkZnIS_ALzfTJ9UbEKPFlQVvIyuHw6AWTJcDFDVgQA2ooQHmH9wUDnhXBi1M7o8yWJ47DXP-TLPhwCgQ" +} +``` + +## Explore Further +- [Minio STS Quickstart Guide](https://docs.minio.io/docs/minio-sts-quickstart-guide) +- [The Minio documentation website](https://docs.minio.io) diff --git a/pkg/auth/credentials.go b/pkg/auth/credentials.go index b84ddafe6..a9b8f1c93 100644 --- a/pkg/auth/credentials.go +++ b/pkg/auth/credentials.go @@ -1,5 +1,5 @@ /* - * Minio Cloud Storage, (C) 2015, 2016, 2017 Minio, Inc. + * Minio Cloud Storage, (C) 2015, 2016, 2017, 2018 Minio, Inc. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. @@ -21,6 +21,9 @@ import ( "crypto/subtle" "encoding/base64" "fmt" + "time" + + jwtgo "github.com/dgrijalva/jwt-go" ) const ( @@ -64,13 +67,29 @@ func isSecretKeyValid(secretKey string) bool { // Credentials holds access and secret keys. type Credentials struct { - AccessKey string `json:"accessKey,omitempty"` - SecretKey string `json:"secretKey,omitempty"` + AccessKey string `xml:"AccessKeyId" json:"accessKey,omitempty"` + SecretKey string `xml:"SecretAccessKey" json:"secretKey,omitempty"` + Expiration time.Time `xml:"Expiration" json:"expiration,omitempty"` + SessionToken string `xml:"SessionToken" json:"sessionToken,omitempty"` + Status string `xml:"-" json:"status,omitempty"` +} + +// IsExpired - returns whether Credential is expired or not. +func (cred Credentials) IsExpired() bool { + if cred.Expiration.IsZero() || cred.Expiration == timeSentinel { + return false + } + + return cred.Expiration.Before(time.Now().UTC()) } // IsValid - returns whether credential is valid or not. func (cred Credentials) IsValid() bool { - return IsAccessKeyValid(cred.AccessKey) && isSecretKeyValid(cred.SecretKey) + // Verify credentials if its enabled or not set. + if cred.Status == "enabled" || cred.Status == "" { + return IsAccessKeyValid(cred.AccessKey) && isSecretKeyValid(cred.SecretKey) && !cred.IsExpired() + } + return false } // Equal - returns whether two credentials are equal or not. @@ -78,11 +97,14 @@ func (cred Credentials) Equal(ccred Credentials) bool { if !ccred.IsValid() { return false } - return cred.AccessKey == ccred.AccessKey && subtle.ConstantTimeCompare([]byte(cred.SecretKey), []byte(ccred.SecretKey)) == 1 + return (cred.AccessKey == ccred.AccessKey && subtle.ConstantTimeCompare([]byte(cred.SecretKey), []byte(ccred.SecretKey)) == 1 && + subtle.ConstantTimeCompare([]byte(cred.SessionToken), []byte(ccred.SessionToken)) == 1) } -// GetNewCredentials generates and returns new credential. -func GetNewCredentials() (cred Credentials, err error) { +var timeSentinel = time.Unix(0, 0).UTC() + +// GetNewCredentialsWithMetadata generates and returns new credential with expiry. +func GetNewCredentialsWithMetadata(m map[string]interface{}, tokenSecret string) (cred Credentials, err error) { readBytes := func(size int) (data []byte, err error) { data = make([]byte, size) var n int @@ -109,11 +131,32 @@ func GetNewCredentials() (cred Credentials, err error) { if err != nil { return cred, err } - cred.SecretKey = string([]byte(base64.StdEncoding.EncodeToString(keyBytes))[:secretKeyMaxLen]) + cred.SecretKey = string([]byte(base64.URLEncoding.EncodeToString(keyBytes))[:secretKeyMaxLen]) + cred.Status = "enabled" + + expiry, ok := m["exp"].(float64) + if !ok { + cred.Expiration = timeSentinel + return cred, nil + } + + m["accessKey"] = cred.AccessKey + jwt := jwtgo.NewWithClaims(jwtgo.SigningMethodHS512, jwtgo.MapClaims(m)) + + cred.Expiration = time.Unix(int64(expiry), 0) + cred.SessionToken, err = jwt.SignedString([]byte(tokenSecret)) + if err != nil { + return cred, err + } return cred, nil } +// GetNewCredentials generates and returns new credential. +func GetNewCredentials() (cred Credentials, err error) { + return GetNewCredentialsWithMetadata(map[string]interface{}{}, "") +} + // CreateCredentials returns new credential with the given access key and secret key. // Error is returned if given access key or secret key are invalid length. func CreateCredentials(accessKey, secretKey string) (cred Credentials, err error) { @@ -123,8 +166,9 @@ func CreateCredentials(accessKey, secretKey string) (cred Credentials, err error if !isSecretKeyValid(secretKey) { return cred, ErrInvalidSecretKeyLength } - cred.AccessKey = accessKey cred.SecretKey = secretKey + cred.Expiration = timeSentinel + cred.Status = "enabled" return cred, nil } diff --git a/pkg/iam/policy/action.go b/pkg/iam/policy/action.go new file mode 100644 index 000000000..f561ebf29 --- /dev/null +++ b/pkg/iam/policy/action.go @@ -0,0 +1,272 @@ +/* + * Minio Cloud Storage, (C) 2018 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 iampolicy + +import ( + "encoding/json" + "fmt" + + "github.com/minio/minio/pkg/policy/condition" + "github.com/minio/minio/pkg/wildcard" +) + +// Action - policy action. +// Refer https://docs.aws.amazon.com/IAM/latest/UserGuide/list_amazons3.html +// for more information about available actions. +type Action string + +const ( + // AbortMultipartUploadAction - AbortMultipartUpload Rest API action. + AbortMultipartUploadAction Action = "s3:AbortMultipartUpload" + + // CreateBucketAction - CreateBucket Rest API action. + CreateBucketAction = "s3:CreateBucket" + + // DeleteBucketAction - DeleteBucket Rest API action. + DeleteBucketAction = "s3:DeleteBucket" + + // DeleteBucketPolicyAction - DeleteBucketPolicy Rest API action. + DeleteBucketPolicyAction = "s3:DeleteBucketPolicy" + + // DeleteObjectAction - DeleteObject Rest API action. + DeleteObjectAction = "s3:DeleteObject" + + // GetBucketLocationAction - GetBucketLocation Rest API action. + GetBucketLocationAction = "s3:GetBucketLocation" + + // GetBucketNotificationAction - GetBucketNotification Rest API action. + GetBucketNotificationAction = "s3:GetBucketNotification" + + // GetBucketPolicyAction - GetBucketPolicy Rest API action. + GetBucketPolicyAction = "s3:GetBucketPolicy" + + // GetObjectAction - GetObject Rest API action. + GetObjectAction = "s3:GetObject" + + // HeadBucketAction - HeadBucket Rest API action. This action is unused in minio. + HeadBucketAction = "s3:HeadBucket" + + // ListAllMyBucketsAction - ListAllMyBuckets (List buckets) Rest API action. + ListAllMyBucketsAction = "s3:ListAllMyBuckets" + + // ListBucketAction - ListBucket Rest API action. + ListBucketAction = "s3:ListBucket" + + // ListBucketMultipartUploadsAction - ListMultipartUploads Rest API action. + ListBucketMultipartUploadsAction = "s3:ListBucketMultipartUploads" + + // ListenBucketNotificationAction - ListenBucketNotification Rest API action. + // This is Minio extension. + ListenBucketNotificationAction = "s3:ListenBucketNotification" + + // ListMultipartUploadPartsAction - ListParts Rest API action. + ListMultipartUploadPartsAction = "s3:ListMultipartUploadParts" + + // PutBucketNotificationAction - PutObjectNotification Rest API action. + PutBucketNotificationAction = "s3:PutBucketNotification" + + // PutBucketPolicyAction - PutBucketPolicy Rest API action. + PutBucketPolicyAction = "s3:PutBucketPolicy" + + // PutObjectAction - PutObject Rest API action. + PutObjectAction = "s3:PutObject" + + // AllActions - all API actions + AllActions = "s3:*" +) + +// List of all supported actions. +var supportedActions = map[Action]struct{}{ + AllActions: {}, + AbortMultipartUploadAction: {}, + CreateBucketAction: {}, + DeleteBucketAction: {}, + DeleteBucketPolicyAction: {}, + DeleteObjectAction: {}, + GetBucketLocationAction: {}, + GetBucketNotificationAction: {}, + GetBucketPolicyAction: {}, + GetObjectAction: {}, + HeadBucketAction: {}, + ListAllMyBucketsAction: {}, + ListBucketAction: {}, + ListBucketMultipartUploadsAction: {}, + ListenBucketNotificationAction: {}, + ListMultipartUploadPartsAction: {}, + PutBucketNotificationAction: {}, + PutBucketPolicyAction: {}, + PutObjectAction: {}, +} + +// isObjectAction - returns whether action is object type or not. +func (action Action) isObjectAction() bool { + switch action { + case AbortMultipartUploadAction, DeleteObjectAction, GetObjectAction: + fallthrough + case ListMultipartUploadPartsAction, PutObjectAction, AllActions: + return true + } + + return false +} + +// Match - matches object name with resource pattern. +func (action Action) Match(a Action) bool { + return wildcard.Match(string(action), string(a)) +} + +// IsValid - checks if action is valid or not. +func (action Action) IsValid() bool { + _, ok := supportedActions[action] + return ok +} + +// MarshalJSON - encodes Action to JSON data. +func (action Action) MarshalJSON() ([]byte, error) { + if action.IsValid() { + return json.Marshal(string(action)) + } + + return nil, fmt.Errorf("invalid action '%v'", action) +} + +// UnmarshalJSON - decodes JSON data to Action. +func (action *Action) UnmarshalJSON(data []byte) error { + var s string + + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + a := Action(s) + if !a.IsValid() { + return fmt.Errorf("invalid action '%v'", s) + } + + *action = a + + return nil +} + +func parseAction(s string) (Action, error) { + action := Action(s) + + if action.IsValid() { + return action, nil + } + + return action, fmt.Errorf("unsupported action '%v'", s) +} + +// actionConditionKeyMap - holds mapping of supported condition key for an action. +var actionConditionKeyMap = map[Action]condition.KeySet{ + AbortMultipartUploadAction: condition.NewKeySet( + condition.AWSReferer, + condition.AWSSourceIP, + ), + + CreateBucketAction: condition.NewKeySet( + condition.AWSReferer, + condition.AWSSourceIP, + ), + + DeleteBucketPolicyAction: condition.NewKeySet( + condition.AWSReferer, + condition.AWSSourceIP, + ), + + DeleteObjectAction: condition.NewKeySet( + condition.AWSReferer, + condition.AWSSourceIP, + ), + + GetBucketLocationAction: condition.NewKeySet( + condition.AWSReferer, + condition.AWSSourceIP, + ), + + GetBucketNotificationAction: condition.NewKeySet( + condition.AWSReferer, + condition.AWSSourceIP, + ), + + GetBucketPolicyAction: condition.NewKeySet( + condition.AWSReferer, + condition.AWSSourceIP, + ), + + GetObjectAction: condition.NewKeySet( + condition.S3XAmzServerSideEncryption, + condition.S3XAmzServerSideEncryptionAwsKMSKeyID, + condition.S3XAmzStorageClass, + condition.AWSReferer, + condition.AWSSourceIP, + ), + + HeadBucketAction: condition.NewKeySet( + condition.AWSReferer, + condition.AWSSourceIP, + ), + + ListAllMyBucketsAction: condition.NewKeySet( + condition.AWSReferer, + condition.AWSSourceIP, + ), + + ListBucketAction: condition.NewKeySet( + condition.S3Prefix, + condition.S3Delimiter, + condition.S3MaxKeys, + condition.AWSReferer, + condition.AWSSourceIP, + ), + + ListBucketMultipartUploadsAction: condition.NewKeySet( + condition.AWSReferer, + condition.AWSSourceIP, + ), + + ListenBucketNotificationAction: condition.NewKeySet( + condition.AWSReferer, + condition.AWSSourceIP, + ), + + ListMultipartUploadPartsAction: condition.NewKeySet( + condition.AWSReferer, + condition.AWSSourceIP, + ), + + PutBucketNotificationAction: condition.NewKeySet( + condition.AWSReferer, + condition.AWSSourceIP, + ), + + PutBucketPolicyAction: condition.NewKeySet( + condition.AWSReferer, + condition.AWSSourceIP, + ), + + PutObjectAction: condition.NewKeySet( + condition.S3XAmzCopySource, + condition.S3XAmzServerSideEncryption, + condition.S3XAmzServerSideEncryptionAwsKMSKeyID, + condition.S3XAmzMetadataDirective, + condition.S3XAmzStorageClass, + condition.AWSReferer, + condition.AWSSourceIP, + ), +} diff --git a/pkg/iam/policy/action_test.go b/pkg/iam/policy/action_test.go new file mode 100644 index 000000000..89c087915 --- /dev/null +++ b/pkg/iam/policy/action_test.go @@ -0,0 +1,116 @@ +/* + * Minio Cloud Storage, (C) 2018 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 iampolicy + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestActionIsObjectAction(t *testing.T) { + testCases := []struct { + action Action + expectedResult bool + }{ + {AbortMultipartUploadAction, true}, + {DeleteObjectAction, true}, + {GetObjectAction, true}, + {ListMultipartUploadPartsAction, true}, + {PutObjectAction, true}, + {CreateBucketAction, false}, + } + + for i, testCase := range testCases { + result := testCase.action.isObjectAction() + + if testCase.expectedResult != result { + t.Fatalf("case %v: expected: %v, got: %v", i+1, testCase.expectedResult, result) + } + } +} + +func TestActionIsValid(t *testing.T) { + testCases := []struct { + action Action + expectedResult bool + }{ + {AbortMultipartUploadAction, true}, + {Action("foo"), false}, + } + + for i, testCase := range testCases { + result := testCase.action.IsValid() + + if testCase.expectedResult != result { + t.Fatalf("case %v: expected: %v, got: %v", i+1, testCase.expectedResult, result) + } + } +} + +func TestActionMarshalJSON(t *testing.T) { + testCases := []struct { + action Action + expectedResult []byte + expectErr bool + }{ + {PutObjectAction, []byte(`"s3:PutObject"`), false}, + {Action("foo"), nil, true}, + } + + for i, testCase := range testCases { + result, err := json.Marshal(testCase.action) + expectErr := (err != nil) + + if testCase.expectErr != expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + + if !testCase.expectErr { + if !reflect.DeepEqual(result, testCase.expectedResult) { + t.Fatalf("case %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result) + } + } + } +} + +func TestActionUnmarshalJSON(t *testing.T) { + testCases := []struct { + data []byte + expectedResult Action + expectErr bool + }{ + {[]byte(`"s3:PutObject"`), PutObjectAction, false}, + {[]byte(`"foo"`), Action(""), true}, + } + + for i, testCase := range testCases { + var result Action + err := json.Unmarshal(testCase.data, &result) + expectErr := (err != nil) + + if testCase.expectErr != expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + + if !testCase.expectErr { + if testCase.expectedResult != result { + t.Fatalf("case %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result) + } + } + } +} diff --git a/pkg/iam/policy/actionset.go b/pkg/iam/policy/actionset.go new file mode 100644 index 000000000..399c421b9 --- /dev/null +++ b/pkg/iam/policy/actionset.go @@ -0,0 +1,119 @@ +/* + * Minio Cloud Storage, (C) 2018 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 iampolicy + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/minio/minio-go/pkg/set" +) + +// ActionSet - set of actions. +type ActionSet map[Action]struct{} + +// Add - add action to the set. +func (actionSet ActionSet) Add(action Action) { + actionSet[action] = struct{}{} +} + +// Match - matches object name with anyone of action pattern in action set. +func (actionSet ActionSet) Match(action Action) bool { + for r := range actionSet { + if r.Match(action) { + return true + } + } + + return false +} + +// Intersection - returns actions available in both ActionSet. +func (actionSet ActionSet) Intersection(sset ActionSet) ActionSet { + nset := NewActionSet() + for k := range actionSet { + if _, ok := sset[k]; ok { + nset.Add(k) + } + } + + return nset +} + +// MarshalJSON - encodes ActionSet to JSON data. +func (actionSet ActionSet) MarshalJSON() ([]byte, error) { + if len(actionSet) == 0 { + return nil, fmt.Errorf("empty action set") + } + + return json.Marshal(actionSet.ToSlice()) +} + +func (actionSet ActionSet) String() string { + actions := []string{} + for action := range actionSet { + actions = append(actions, string(action)) + } + sort.Strings(actions) + + return fmt.Sprintf("%v", actions) +} + +// ToSlice - returns slice of actions from the action set. +func (actionSet ActionSet) ToSlice() []Action { + actions := []Action{} + for action := range actionSet { + actions = append(actions, action) + } + + return actions +} + +// UnmarshalJSON - decodes JSON data to ActionSet. +func (actionSet *ActionSet) UnmarshalJSON(data []byte) error { + var sset set.StringSet + if err := json.Unmarshal(data, &sset); err != nil { + return err + } + + if len(sset) == 0 { + return fmt.Errorf("empty action set") + } + + *actionSet = make(ActionSet) + for _, s := range sset.ToSlice() { + action, err := parseAction(s) + if err != nil { + return err + } + + actionSet.Add(action) + } + + return nil +} + +// NewActionSet - creates new action set. +func NewActionSet(actions ...Action) ActionSet { + actionSet := make(ActionSet) + for _, action := range actions { + actionSet.Add(action) + } + + return actionSet +} diff --git a/pkg/iam/policy/actionset_test.go b/pkg/iam/policy/actionset_test.go new file mode 100644 index 000000000..3121c8a92 --- /dev/null +++ b/pkg/iam/policy/actionset_test.go @@ -0,0 +1,159 @@ +/* + * Minio Cloud Storage, (C) 2018 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 iampolicy + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestActionSetAdd(t *testing.T) { + testCases := []struct { + set ActionSet + action Action + expectedResult ActionSet + }{ + {NewActionSet(), PutObjectAction, NewActionSet(PutObjectAction)}, + {NewActionSet(PutObjectAction), PutObjectAction, NewActionSet(PutObjectAction)}, + } + + for i, testCase := range testCases { + testCase.set.Add(testCase.action) + + if !reflect.DeepEqual(testCase.expectedResult, testCase.set) { + t.Fatalf("case %v: expected: %v, got: %v\n", i+1, testCase.expectedResult, testCase.set) + } + } +} + +func TestActionSetMatches(t *testing.T) { + testCases := []struct { + set ActionSet + action Action + expectedResult bool + }{ + {NewActionSet(AllActions), AbortMultipartUploadAction, true}, + {NewActionSet(PutObjectAction), PutObjectAction, true}, + {NewActionSet(PutObjectAction, GetObjectAction), PutObjectAction, true}, + {NewActionSet(PutObjectAction, GetObjectAction), AbortMultipartUploadAction, false}, + } + + for i, testCase := range testCases { + result := testCase.set.Match(testCase.action) + + if result != testCase.expectedResult { + t.Fatalf("case %v: expected: %v, got: %v\n", i+1, testCase.expectedResult, result) + } + } +} + +func TestActionSetIntersection(t *testing.T) { + testCases := []struct { + set ActionSet + setToIntersect ActionSet + expectedResult ActionSet + }{ + {NewActionSet(), NewActionSet(PutObjectAction), NewActionSet()}, + {NewActionSet(PutObjectAction), NewActionSet(), NewActionSet()}, + {NewActionSet(PutObjectAction), NewActionSet(PutObjectAction, GetObjectAction), NewActionSet(PutObjectAction)}, + } + + for i, testCase := range testCases { + result := testCase.set.Intersection(testCase.setToIntersect) + + if !reflect.DeepEqual(result, testCase.expectedResult) { + t.Fatalf("case %v: expected: %v, got: %v\n", i+1, testCase.expectedResult, testCase.set) + } + } +} + +func TestActionSetMarshalJSON(t *testing.T) { + testCases := []struct { + actionSet ActionSet + expectedResult []byte + expectErr bool + }{ + {NewActionSet(PutObjectAction), []byte(`["s3:PutObject"]`), false}, + {NewActionSet(), nil, true}, + } + + for i, testCase := range testCases { + result, err := json.Marshal(testCase.actionSet) + expectErr := (err != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v\n", i+1, testCase.expectErr, expectErr) + } + + if !testCase.expectErr { + if !reflect.DeepEqual(result, testCase.expectedResult) { + t.Fatalf("case %v: result: expected: %v, got: %v\n", i+1, string(testCase.expectedResult), string(result)) + } + } + } +} + +func TestActionSetToSlice(t *testing.T) { + testCases := []struct { + actionSet ActionSet + expectedResult []Action + }{ + {NewActionSet(PutObjectAction), []Action{PutObjectAction}}, + {NewActionSet(), []Action{}}, + } + + for i, testCase := range testCases { + result := testCase.actionSet.ToSlice() + + if !reflect.DeepEqual(result, testCase.expectedResult) { + t.Fatalf("case %v: result: expected: %v, got: %v\n", i+1, testCase.expectedResult, result) + } + } +} + +func TestActionSetUnmarshalJSON(t *testing.T) { + testCases := []struct { + data []byte + expectedResult ActionSet + expectErr bool + }{ + {[]byte(`"s3:PutObject"`), NewActionSet(PutObjectAction), false}, + {[]byte(`["s3:PutObject"]`), NewActionSet(PutObjectAction), false}, + {[]byte(`["s3:PutObject", "s3:GetObject"]`), NewActionSet(PutObjectAction, GetObjectAction), false}, + {[]byte(`["s3:PutObject", "s3:GetObject", "s3:PutObject"]`), NewActionSet(PutObjectAction, GetObjectAction), false}, + {[]byte(`[]`), NewActionSet(), true}, // Empty array. + {[]byte(`"foo"`), nil, true}, // Invalid action. + {[]byte(`["s3:PutObject", "foo"]`), nil, true}, // Invalid action. + } + + for i, testCase := range testCases { + result := make(ActionSet) + err := json.Unmarshal(testCase.data, &result) + expectErr := (err != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v\n", i+1, testCase.expectErr, expectErr) + } + + if !testCase.expectErr { + if !reflect.DeepEqual(result, testCase.expectedResult) { + t.Fatalf("case %v: result: expected: %v, got: %v\n", i+1, testCase.expectedResult, result) + } + } + } +} diff --git a/pkg/iam/policy/opa.go b/pkg/iam/policy/opa.go new file mode 100644 index 000000000..61193284a --- /dev/null +++ b/pkg/iam/policy/opa.go @@ -0,0 +1,172 @@ +/* + * Minio Cloud Storage, (C) 2018 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 iampolicy + +import ( + "bytes" + "crypto/tls" + "encoding/json" + "net" + "net/http" + "os" + "time" + + xnet "github.com/minio/minio/pkg/net" +) + +// OpaArgs opa general purpose policy engine configuration. +type OpaArgs struct { + URL *xnet.URL `json:"url"` + AuthToken string `json:"authToken"` +} + +// Validate - validate opa configuration params. +func (a *OpaArgs) Validate() error { + return nil +} + +// UnmarshalJSON - decodes JSON data. +func (a *OpaArgs) UnmarshalJSON(data []byte) error { + // subtype to avoid recursive call to UnmarshalJSON() + type subOpaArgs OpaArgs + var so subOpaArgs + + if opaURL, ok := os.LookupEnv("MINIO_IAM_OPA_URL"); ok { + u, err := xnet.ParseURL(opaURL) + if err != nil { + return err + } + so.URL = u + so.AuthToken = os.Getenv("MINIO_IAM_OPA_AUTHTOKEN") + } else { + if err := json.Unmarshal(data, &so); err != nil { + return err + } + } + + oa := OpaArgs(so) + if oa.URL == nil || oa.URL.String() == "" { + *a = oa + return nil + } + + if err := oa.Validate(); err != nil { + return err + } + + *a = oa + return nil +} + +// Opa - implements opa policy agent calls. +type Opa struct { + args OpaArgs + secureFailed bool + client *http.Client + insecureClient *http.Client +} + +// newCustomHTTPTransport returns a new http configuration +// used while communicating with the cloud backends. +// This sets the value for MaxIdleConnsPerHost from 2 (go default) +// to 100. +func newCustomHTTPTransport(insecure bool) *http.Transport { + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 1024, + MaxIdleConnsPerHost: 1024, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}, + DisableCompression: true, + } +} + +// NewOpa - initializes opa policy engine connector. +func NewOpa(args OpaArgs) *Opa { + // No opa args. + if args.URL == nil && args.AuthToken == "" { + return nil + } + return &Opa{ + args: args, + client: &http.Client{Transport: newCustomHTTPTransport(false)}, + insecureClient: &http.Client{Transport: newCustomHTTPTransport(true)}, + } +} + +// IsAllowed - checks given policy args is allowed to continue the REST API. +func (o *Opa) IsAllowed(args Args) bool { + if o == nil { + return false + } + + // OPA input + body := make(map[string]interface{}) + body["input"] = args + + inputBytes, err := json.Marshal(body) + if err != nil { + return false + } + + req, err := http.NewRequest("POST", o.args.URL.String(), bytes.NewReader(inputBytes)) + if err != nil { + return false + } + + req.Header.Set("Content-Type", "application/json") + if o.args.AuthToken != "" { + req.Header.Set("Authorization", o.args.AuthToken) + } + + var resp *http.Response + if o.secureFailed { + resp, err = o.insecureClient.Do(req) + } else { + resp, err = o.client.Do(req) + if err != nil { + o.secureFailed = true + resp, err = o.insecureClient.Do(req) + if err != nil { + return false + } + } + } + if err != nil { + return false + } + defer resp.Body.Close() + + // Handle OPA response + type opaResponse struct { + Result struct { + Allow bool `json:"allow"` + } `json:"result"` + } + var result opaResponse + if err = json.NewDecoder(resp.Body).Decode(&result); err != nil { + return false + } + + return result.Result.Allow +} diff --git a/pkg/iam/policy/policy.go b/pkg/iam/policy/policy.go new file mode 100644 index 000000000..64491b7b2 --- /dev/null +++ b/pkg/iam/policy/policy.go @@ -0,0 +1,173 @@ +/* + * Minio Cloud Storage, (C) 2018 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 iampolicy + +import ( + "encoding/json" + "fmt" + "io" + + "github.com/minio/minio/pkg/policy" +) + +// DefaultVersion - default policy version as per AWS S3 specification. +const DefaultVersion = "2012-10-17" + +// Args - arguments to policy to check whether it is allowed +type Args struct { + AccountName string `json:"account"` + Action Action `json:"action"` + BucketName string `json:"bucket"` + ConditionValues map[string][]string `json:"conditions"` + IsOwner bool `json:"owner"` + ObjectName string `json:"object"` + Claims map[string]interface{} `json:"claims"` +} + +// Policy - iam bucket iamp. +type Policy struct { + ID policy.ID `json:"ID,omitempty"` + Version string + Statements []Statement `json:"Statement"` +} + +// IsAllowed - checks given policy args is allowed to continue the Rest API. +func (iamp Policy) IsAllowed(args Args) bool { + // Check all deny statements. If any one statement denies, return false. + for _, statement := range iamp.Statements { + if statement.Effect == policy.Deny { + if !statement.IsAllowed(args) { + return false + } + } + } + + // For owner, its allowed by default. + if args.IsOwner { + return true + } + + // Check all allow statements. If any one statement allows, return true. + for _, statement := range iamp.Statements { + if statement.Effect == policy.Allow { + if statement.IsAllowed(args) { + return true + } + } + } + + return false +} + +// IsEmpty - returns whether policy is empty or not. +func (iamp Policy) IsEmpty() bool { + return len(iamp.Statements) == 0 +} + +// isValid - checks if Policy is valid or not. +func (iamp Policy) isValid() error { + if iamp.Version != DefaultVersion && iamp.Version != "" { + return fmt.Errorf("invalid version '%v'", iamp.Version) + } + + for _, statement := range iamp.Statements { + if err := statement.isValid(); err != nil { + return err + } + } + + for i := range iamp.Statements { + for _, statement := range iamp.Statements[i+1:] { + actions := iamp.Statements[i].Actions.Intersection(statement.Actions) + if len(actions) == 0 { + continue + } + + resources := iamp.Statements[i].Resources.Intersection(statement.Resources) + if len(resources) == 0 { + continue + } + + if iamp.Statements[i].Conditions.String() != statement.Conditions.String() { + continue + } + + return fmt.Errorf("duplicate actions %v, resources %v found in statements %v, %v", + actions, resources, iamp.Statements[i], statement) + } + } + + return nil +} + +// MarshalJSON - encodes Policy to JSON data. +func (iamp Policy) MarshalJSON() ([]byte, error) { + if err := iamp.isValid(); err != nil { + return nil, err + } + + // subtype to avoid recursive call to MarshalJSON() + type subPolicy Policy + return json.Marshal(subPolicy(iamp)) +} + +// UnmarshalJSON - decodes JSON data to Iamp. +func (iamp *Policy) UnmarshalJSON(data []byte) error { + // subtype to avoid recursive call to UnmarshalJSON() + type subPolicy Policy + var sp subPolicy + if err := json.Unmarshal(data, &sp); err != nil { + return err + } + + p := Policy(sp) + if err := p.isValid(); err != nil { + return err + } + + *iamp = p + + return nil +} + +// Validate - validates all statements are for given bucket or not. +func (iamp Policy) Validate() error { + if err := iamp.isValid(); err != nil { + return err + } + + for _, statement := range iamp.Statements { + if err := statement.Validate(); err != nil { + return err + } + } + + return nil +} + +// ParseConfig - parses data in given reader to Iamp. +func ParseConfig(reader io.Reader) (*Policy, error) { + var iamp Policy + + decoder := json.NewDecoder(reader) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&iamp); err != nil { + return nil, err + } + + return &iamp, iamp.Validate() +} diff --git a/pkg/iam/policy/policy_test.go b/pkg/iam/policy/policy_test.go new file mode 100644 index 000000000..a6f57cff5 --- /dev/null +++ b/pkg/iam/policy/policy_test.go @@ -0,0 +1,1032 @@ +/* + * Minio Cloud Storage, (C) 2018 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 iampolicy + +import ( + "encoding/json" + "net" + "reflect" + "testing" + + "github.com/minio/minio/pkg/policy" + "github.com/minio/minio/pkg/policy/condition" +) + +func TestPolicyIsAllowed(t *testing.T) { + case1Policy := Policy{ + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(GetBucketLocationAction, PutObjectAction), + NewResourceSet(NewResource("*", "")), + condition.NewFunctions(), + )}, + } + + case2Policy := Policy{ + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(GetObjectAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + )}, + } + + _, IPNet, err := net.ParseCIDR("192.168.1.0/24") + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + func1, err := condition.NewIPAddressFunc( + condition.AWSSourceIP, + IPNet, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + + case3Policy := Policy{ + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(GetObjectAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(func1), + )}, + } + + case4Policy := Policy{ + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Deny, + NewActionSet(GetObjectAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(func1), + )}, + } + + anonGetBucketLocationArgs := Args{ + AccountName: "Q3AM3UQ867SPQQA43P2F", + Action: GetBucketLocationAction, + BucketName: "mybucket", + ConditionValues: map[string][]string{}, + } + + anonPutObjectActionArgs := Args{ + AccountName: "Q3AM3UQ867SPQQA43P2F", + Action: PutObjectAction, + BucketName: "mybucket", + ConditionValues: map[string][]string{ + "x-amz-copy-source": {"mybucket/myobject"}, + "SourceIp": {"192.168.1.10"}, + }, + ObjectName: "myobject", + } + + anonGetObjectActionArgs := Args{ + AccountName: "Q3AM3UQ867SPQQA43P2F", + Action: GetObjectAction, + BucketName: "mybucket", + ConditionValues: map[string][]string{}, + ObjectName: "myobject", + } + + getBucketLocationArgs := Args{ + AccountName: "Q3AM3UQ867SPQQA43P2F", + Action: GetBucketLocationAction, + BucketName: "mybucket", + ConditionValues: map[string][]string{}, + } + + putObjectActionArgs := Args{ + AccountName: "Q3AM3UQ867SPQQA43P2F", + Action: PutObjectAction, + BucketName: "mybucket", + ConditionValues: map[string][]string{ + "x-amz-copy-source": {"mybucket/myobject"}, + "SourceIp": {"192.168.1.10"}, + }, + ObjectName: "myobject", + } + + getObjectActionArgs := Args{ + AccountName: "Q3AM3UQ867SPQQA43P2F", + Action: GetObjectAction, + BucketName: "mybucket", + ConditionValues: map[string][]string{}, + ObjectName: "myobject", + } + + testCases := []struct { + policy Policy + args Args + expectedResult bool + }{ + {case1Policy, anonGetBucketLocationArgs, true}, + {case1Policy, anonPutObjectActionArgs, true}, + {case1Policy, anonGetObjectActionArgs, false}, + {case1Policy, getBucketLocationArgs, true}, + {case1Policy, putObjectActionArgs, true}, + {case1Policy, getObjectActionArgs, false}, + + {case2Policy, anonGetBucketLocationArgs, false}, + {case2Policy, anonPutObjectActionArgs, true}, + {case2Policy, anonGetObjectActionArgs, true}, + {case2Policy, getBucketLocationArgs, false}, + {case2Policy, putObjectActionArgs, true}, + {case2Policy, getObjectActionArgs, true}, + + {case3Policy, anonGetBucketLocationArgs, false}, + {case3Policy, anonPutObjectActionArgs, true}, + {case3Policy, anonGetObjectActionArgs, false}, + {case3Policy, getBucketLocationArgs, false}, + {case3Policy, putObjectActionArgs, true}, + {case3Policy, getObjectActionArgs, false}, + + {case4Policy, anonGetBucketLocationArgs, false}, + {case4Policy, anonPutObjectActionArgs, false}, + {case4Policy, anonGetObjectActionArgs, false}, + {case4Policy, getBucketLocationArgs, false}, + {case4Policy, putObjectActionArgs, false}, + {case4Policy, getObjectActionArgs, false}, + } + + for i, testCase := range testCases { + result := testCase.policy.IsAllowed(testCase.args) + + if result != testCase.expectedResult { + t.Errorf("case %v: expected: %v, got: %v\n", i+1, testCase.expectedResult, result) + } + } +} + +func TestPolicyIsEmpty(t *testing.T) { + case1Policy := Policy{ + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + }, + } + + case2Policy := Policy{ + ID: "MyPolicyForMyBucket", + Version: DefaultVersion, + } + + testCases := []struct { + policy Policy + expectedResult bool + }{ + {case1Policy, false}, + {case2Policy, true}, + } + + for i, testCase := range testCases { + result := testCase.policy.IsEmpty() + + if result != testCase.expectedResult { + t.Fatalf("case %v: expected: %v, got: %v\n", i+1, testCase.expectedResult, result) + } + } +} + +func TestPolicyIsValid(t *testing.T) { + case1Policy := Policy{ + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + }, + } + + case2Policy := Policy{ + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + NewStatement( + policy.Deny, + NewActionSet(GetObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + }, + } + + case3Policy := Policy{ + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + NewStatement( + policy.Deny, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/yourobject*")), + condition.NewFunctions(), + ), + }, + } + + func1, err := condition.NewNullFunc( + condition.S3XAmzCopySource, + true, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + func2, err := condition.NewNullFunc( + condition.S3XAmzServerSideEncryption, + false, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + + case4Policy := Policy{ + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(func1), + ), + NewStatement( + policy.Deny, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(func2), + ), + }, + } + + case5Policy := Policy{ + Version: "17-10-2012", + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + }, + } + + case6Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(GetObjectAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "myobject*")), + condition.NewFunctions(func1, func2), + ), + }, + } + + case7Policy := Policy{ + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + NewStatement( + policy.Deny, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + }, + } + + testCases := []struct { + policy Policy + expectErr bool + }{ + {case1Policy, false}, + // allowed duplicate principal. + {case2Policy, false}, + // allowed duplicate principal and action. + {case3Policy, false}, + // allowed duplicate principal, action and resource. + {case4Policy, false}, + // Invalid version error. + {case5Policy, true}, + // Invalid statement error. + {case6Policy, true}, + // Duplicate statement error. + {case7Policy, true}, + } + + for i, testCase := range testCases { + err := testCase.policy.isValid() + expectErr := (err != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + } +} + +func TestPolicyMarshalJSON(t *testing.T) { + case1Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + }, + } + case1Policy.Statements[0].SID = "SomeId1" + case1Data := []byte(`{"ID":"MyPolicyForMyBucket1","Version":"2012-10-17","Statement":[{"Sid":"SomeId1","Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::mybucket/myobject*"]}]}`) + + _, IPNet1, err := net.ParseCIDR("192.168.1.0/24") + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + func1, err := condition.NewIPAddressFunc( + condition.AWSSourceIP, + IPNet1, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + + case2Policy := Policy{ + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + NewStatement( + policy.Deny, + NewActionSet(GetObjectAction), + NewResourceSet(NewResource("mybucket", "/yourobject*")), + condition.NewFunctions(func1), + ), + }, + } + case2Data := []byte(`{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::mybucket/myobject*"]},{"Effect":"Deny","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::mybucket/yourobject*"],"Condition":{"IpAddress":{"aws:SourceIp":["192.168.1.0/24"]}}}]}`) + + case3Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(GetObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + }, + } + case3Data := []byte(`{"ID":"MyPolicyForMyBucket1","Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::mybucket/myobject*"]},{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::mybucket/myobject*"]}]}`) + + case4Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + NewStatement( + policy.Allow, + NewActionSet(GetObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + }, + } + case4Data := []byte(`{"ID":"MyPolicyForMyBucket1","Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::mybucket/myobject*"]},{"Effect":"Allow","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::mybucket/myobject*"]}]}`) + + case5Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/yourobject*")), + condition.NewFunctions(), + ), + }, + } + case5Data := []byte(`{"ID":"MyPolicyForMyBucket1","Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::mybucket/myobject*"]},{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::mybucket/yourobject*"]}]}`) + + _, IPNet2, err := net.ParseCIDR("192.168.2.0/24") + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + func2, err := condition.NewIPAddressFunc( + condition.AWSSourceIP, + IPNet2, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + + case6Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(func1), + ), + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(func2), + ), + }, + } + case6Data := []byte(`{"ID":"MyPolicyForMyBucket1","Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::mybucket/myobject*"],"Condition":{"IpAddress":{"aws:SourceIp":["192.168.1.0/24"]}}},{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::mybucket/myobject*"],"Condition":{"IpAddress":{"aws:SourceIp":["192.168.2.0/24"]}}}]}`) + + case7Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(GetBucketLocationAction), + NewResourceSet(NewResource("mybucket", "")), + condition.NewFunctions(), + ), + }, + } + case7Data := []byte(`{"ID":"MyPolicyForMyBucket1","Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetBucketLocation"],"Resource":["arn:aws:s3:::mybucket"]}]}`) + + case8Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(GetBucketLocationAction), + NewResourceSet(NewResource("*", "")), + condition.NewFunctions(), + ), + }, + } + case8Data := []byte(`{"ID":"MyPolicyForMyBucket1","Version":"2012-10-17","Statement":[{"Effect":"Allow","Action":["s3:GetBucketLocation"],"Resource":["arn:aws:s3:::*"]}]}`) + + func3, err := condition.NewNullFunc( + condition.S3XAmzCopySource, + true, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + case9Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(GetObjectAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "myobject*")), + condition.NewFunctions(func1, func2, func3), + ), + }, + } + + testCases := []struct { + policy Policy + expectedResult []byte + expectErr bool + }{ + {case1Policy, case1Data, false}, + {case2Policy, case2Data, false}, + {case3Policy, case3Data, false}, + {case4Policy, case4Data, false}, + {case5Policy, case5Data, false}, + {case6Policy, case6Data, false}, + {case7Policy, case7Data, false}, + {case8Policy, case8Data, false}, + {case9Policy, nil, true}, + } + + for i, testCase := range testCases { + result, err := json.Marshal(testCase.policy) + expectErr := (err != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + + if !testCase.expectErr { + if !reflect.DeepEqual(result, testCase.expectedResult) { + t.Fatalf("case %v: result: expected: %v, got: %v", i+1, string(testCase.expectedResult), string(result)) + } + } + } +} + +func TestPolicyUnmarshalJSON(t *testing.T) { + case1Data := []byte(`{ + "ID": "MyPolicyForMyBucket1", + "Version": "2012-10-17", + "Statement": [ + { + "Sid": "SomeId1", + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*" + } + ] +}`) + case1Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + }, + } + case1Policy.Statements[0].SID = "SomeId1" + + case2Data := []byte(`{ + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*" + }, + { + "Effect": "Deny", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::mybucket/yourobject*", + "Condition": { + "IpAddress": { + "aws:SourceIp": "192.168.1.0/24" + } + } + } + ] +}`) + _, IPNet1, err := net.ParseCIDR("192.168.1.0/24") + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + func1, err := condition.NewIPAddressFunc( + condition.AWSSourceIP, + IPNet1, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + + case2Policy := Policy{ + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + NewStatement( + policy.Deny, + NewActionSet(GetObjectAction), + NewResourceSet(NewResource("mybucket", "/yourobject*")), + condition.NewFunctions(func1), + ), + }, + } + + case3Data := []byte(`{ + "ID": "MyPolicyForMyBucket1", + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::mybucket/myobject*" + }, + { + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*" + } + ] +}`) + case3Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(GetObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + }, + } + + case4Data := []byte(`{ + "ID": "MyPolicyForMyBucket1", + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*" + }, + { + "Effect": "Allow", + "Action": "s3:GetObject", + "Resource": "arn:aws:s3:::mybucket/myobject*" + } + ] +}`) + case4Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + NewStatement( + policy.Allow, + NewActionSet(GetObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + }, + } + + case5Data := []byte(`{ + "ID": "MyPolicyForMyBucket1", + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*" + }, + { + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/yourobject*" + } + ] +}`) + case5Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ), + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/yourobject*")), + condition.NewFunctions(), + ), + }, + } + + case6Data := []byte(`{ + "ID": "MyPolicyForMyBucket1", + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*", + "Condition": { + "IpAddress": { + "aws:SourceIp": "192.168.1.0/24" + } + } + }, + { + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*", + "Condition": { + "IpAddress": { + "aws:SourceIp": "192.168.2.0/24" + } + } + } + ] +}`) + _, IPNet2, err := net.ParseCIDR("192.168.2.0/24") + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + func2, err := condition.NewIPAddressFunc( + condition.AWSSourceIP, + IPNet2, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + + case6Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(func1), + ), + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(func2), + ), + }, + } + + case7Data := []byte(`{ + "ID": "MyPolicyForMyBucket1", + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:GetBucketLocation", + "Resource": "arn:aws:s3:::mybucket" + } + ] +}`) + + case7Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(GetBucketLocationAction), + NewResourceSet(NewResource("mybucket", "")), + condition.NewFunctions(), + ), + }, + } + + case8Data := []byte(`{ + "ID": "MyPolicyForMyBucket1", + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:GetBucketLocation", + "Resource": "arn:aws:s3:::*" + } + ] +}`) + + case8Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(GetBucketLocationAction), + NewResourceSet(NewResource("*", "")), + condition.NewFunctions(), + ), + }, + } + + case9Data := []byte(`{ + "ID": "MyPolicyForMyBucket1", + "Version": "17-10-2012", + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*" + } + ] +}`) + + case10Data := []byte(`{ + "ID": "MyPolicyForMyBucket1", + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*" + }, + { + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*" + } + ] +}`) + + case11Data := []byte(`{ + "ID": "MyPolicyForMyBucket1", + "Version": "2012-10-17", + "Statement": [ + { + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*" + }, + { + "Effect": "Deny", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*" + } + ] +}`) + + testCases := []struct { + data []byte + expectedResult Policy + expectErr bool + }{ + {case1Data, case1Policy, false}, + {case2Data, case2Policy, false}, + {case3Data, case3Policy, false}, + {case4Data, case4Policy, false}, + {case5Data, case5Policy, false}, + {case6Data, case6Policy, false}, + {case7Data, case7Policy, false}, + {case8Data, case8Policy, false}, + // Invalid version error. + {case9Data, Policy{}, true}, + // Duplicate statement error. + {case10Data, Policy{}, true}, + // Duplicate statement error (Effect differs). + {case11Data, Policy{}, true}, + } + + for i, testCase := range testCases { + var result Policy + err := json.Unmarshal(testCase.data, &result) + expectErr := (err != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + + if !testCase.expectErr { + if !reflect.DeepEqual(result, testCase.expectedResult) { + t.Fatalf("case %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result) + } + } + } +} + +func TestPolicyValidate(t *testing.T) { + case1Policy := Policy{ + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("", "")), + condition.NewFunctions(), + ), + }, + } + + func1, err := condition.NewNullFunc( + condition.S3XAmzCopySource, + true, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + func2, err := condition.NewNullFunc( + condition.S3XAmzServerSideEncryption, + false, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + case2Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(GetObjectAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "myobject*")), + condition.NewFunctions(func1, func2), + ), + }, + } + + case3Policy := Policy{ + ID: "MyPolicyForMyBucket1", + Version: DefaultVersion, + Statements: []Statement{ + NewStatement( + policy.Allow, + NewActionSet(GetObjectAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "myobject*")), + condition.NewFunctions(), + ), + }, + } + + testCases := []struct { + policy Policy + expectErr bool + }{ + {case1Policy, true}, + {case2Policy, true}, + {case3Policy, false}, + } + + for i, testCase := range testCases { + err := testCase.policy.Validate() + expectErr := (err != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + } +} diff --git a/pkg/iam/policy/resource.go b/pkg/iam/policy/resource.go new file mode 100644 index 000000000..eb1e36d9a --- /dev/null +++ b/pkg/iam/policy/resource.go @@ -0,0 +1,129 @@ +/* + * Minio Cloud Storage, (C) 2018 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 iampolicy + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/minio/minio/pkg/wildcard" +) + +// ResourceARNPrefix - resource ARN prefix as per AWS S3 specification. +const ResourceARNPrefix = "arn:aws:s3:::" + +// Resource - resource in policy statement. +type Resource struct { + BucketName string + Pattern string +} + +func (r Resource) isBucketPattern() bool { + return !strings.Contains(r.Pattern, "/") || r.Pattern == "*" +} + +func (r Resource) isObjectPattern() bool { + return strings.Contains(r.Pattern, "/") || strings.Contains(r.BucketName, "*") || r.Pattern == "*/*" +} + +// IsValid - checks whether Resource is valid or not. +func (r Resource) IsValid() bool { + return r.Pattern != "" +} + +// Match - matches object name with resource pattern. +func (r Resource) Match(resource string) bool { + if strings.HasPrefix(resource, r.Pattern) { + return true + } + return wildcard.Match(r.Pattern, resource) +} + +// MarshalJSON - encodes Resource to JSON data. +func (r Resource) MarshalJSON() ([]byte, error) { + if !r.IsValid() { + return nil, fmt.Errorf("invalid resource %v", r) + } + + return json.Marshal(r.String()) +} + +func (r Resource) String() string { + return ResourceARNPrefix + r.Pattern +} + +// UnmarshalJSON - decodes JSON data to Resource. +func (r *Resource) UnmarshalJSON(data []byte) error { + var s string + if err := json.Unmarshal(data, &s); err != nil { + return err + } + + parsedResource, err := parseResource(s) + if err != nil { + return err + } + + *r = parsedResource + + return nil +} + +// Validate - validates Resource is for given bucket or not. +func (r Resource) Validate() error { + if !r.IsValid() { + return fmt.Errorf("invalid resource") + } + return nil +} + +// parseResource - parses string to Resource. +func parseResource(s string) (Resource, error) { + if !strings.HasPrefix(s, ResourceARNPrefix) { + return Resource{}, fmt.Errorf("invalid resource '%v'", s) + } + + pattern := strings.TrimPrefix(s, ResourceARNPrefix) + tokens := strings.SplitN(pattern, "/", 2) + bucketName := tokens[0] + if bucketName == "" { + return Resource{}, fmt.Errorf("invalid resource format '%v'", s) + } + + return Resource{ + BucketName: bucketName, + Pattern: pattern, + }, nil +} + +// NewResource - creates new resource. +func NewResource(bucketName, keyName string) Resource { + pattern := bucketName + if keyName != "" { + if !strings.HasPrefix(keyName, "/") { + pattern += "/" + } + + pattern += keyName + } + + return Resource{ + BucketName: bucketName, + Pattern: pattern, + } +} diff --git a/pkg/iam/policy/resource_test.go b/pkg/iam/policy/resource_test.go new file mode 100644 index 000000000..102a89c22 --- /dev/null +++ b/pkg/iam/policy/resource_test.go @@ -0,0 +1,220 @@ +/* + * Minio Cloud Storage, (C) 2018 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 iampolicy + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestResourceIsBucketPattern(t *testing.T) { + testCases := []struct { + resource Resource + expectedResult bool + }{ + {NewResource("*", ""), true}, + {NewResource("mybucket", ""), true}, + {NewResource("mybucket*", ""), true}, + {NewResource("mybucket?0", ""), true}, + {NewResource("", "*"), false}, + {NewResource("*", "*"), false}, + {NewResource("mybucket", "*"), false}, + {NewResource("mybucket*", "/myobject"), false}, + {NewResource("mybucket?0", "/2010/photos/*"), false}, + } + + for i, testCase := range testCases { + result := testCase.resource.isBucketPattern() + + if result != testCase.expectedResult { + t.Fatalf("case %v: expected: %v, got: %v", i+1, testCase.expectedResult, result) + } + } +} + +func TestResourceIsObjectPattern(t *testing.T) { + testCases := []struct { + resource Resource + expectedResult bool + }{ + {NewResource("*", ""), true}, + {NewResource("mybucket*", ""), true}, + {NewResource("", "*"), true}, + {NewResource("*", "*"), true}, + {NewResource("mybucket", "*"), true}, + {NewResource("mybucket*", "/myobject"), true}, + {NewResource("mybucket?0", "/2010/photos/*"), true}, + {NewResource("mybucket", ""), false}, + {NewResource("mybucket?0", ""), false}, + } + + for i, testCase := range testCases { + result := testCase.resource.isObjectPattern() + + if result != testCase.expectedResult { + t.Fatalf("case %v: expected: %v, got: %v", i+1, testCase.expectedResult, result) + } + } +} + +func TestResourceIsValid(t *testing.T) { + testCases := []struct { + resource Resource + expectedResult bool + }{ + {NewResource("*", ""), true}, + {NewResource("mybucket*", ""), true}, + {NewResource("*", "*"), true}, + {NewResource("mybucket", "*"), true}, + {NewResource("mybucket*", "/myobject"), true}, + {NewResource("mybucket?0", "/2010/photos/*"), true}, + {NewResource("mybucket", ""), true}, + {NewResource("mybucket?0", ""), true}, + {NewResource("", "*"), true}, + {NewResource("", ""), false}, + } + + for i, testCase := range testCases { + result := testCase.resource.IsValid() + + if result != testCase.expectedResult { + t.Fatalf("case %v: expected: %v, got: %v", i+1, testCase.expectedResult, result) + } + } +} + +func TestResourceMatch(t *testing.T) { + testCases := []struct { + resource Resource + objectName string + expectedResult bool + }{ + {NewResource("*", ""), "mybucket", true}, + {NewResource("*", ""), "mybucket/myobject", true}, + {NewResource("mybucket*", ""), "mybucket", true}, + {NewResource("mybucket*", ""), "mybucket/myobject", true}, + {NewResource("", "*"), "/myobject", true}, + {NewResource("*", "*"), "mybucket/myobject", true}, + {NewResource("mybucket", "*"), "mybucket/myobject", true}, + {NewResource("mybucket*", "/myobject"), "mybucket/myobject", true}, + {NewResource("mybucket*", "/myobject"), "mybucket100/myobject", true}, + {NewResource("mybucket?0", "/2010/photos/*"), "mybucket20/2010/photos/1.jpg", true}, + {NewResource("mybucket", ""), "mybucket", true}, + {NewResource("mybucket?0", ""), "mybucket30", true}, + {NewResource("", "*"), "mybucket/myobject", false}, + {NewResource("*", "*"), "mybucket", false}, + {NewResource("mybucket", "*"), "mybucket10/myobject", false}, + {NewResource("mybucket?0", "/2010/photos/*"), "mybucket0/2010/photos/1.jpg", false}, + {NewResource("mybucket", ""), "mybucket/myobject", true}, + } + + for i, testCase := range testCases { + result := testCase.resource.Match(testCase.objectName) + + if result != testCase.expectedResult { + t.Fatalf("case %v: expected: %v, got: %v", i+1, testCase.expectedResult, result) + } + } +} + +func TestResourceMarshalJSON(t *testing.T) { + testCases := []struct { + resource Resource + expectedResult []byte + expectErr bool + }{ + {NewResource("*", ""), []byte(`"arn:aws:s3:::*"`), false}, + {NewResource("mybucket*", ""), []byte(`"arn:aws:s3:::mybucket*"`), false}, + {NewResource("mybucket", ""), []byte(`"arn:aws:s3:::mybucket"`), false}, + {NewResource("*", "*"), []byte(`"arn:aws:s3:::*/*"`), false}, + {NewResource("", "*"), []byte(`"arn:aws:s3:::/*"`), false}, + {NewResource("mybucket", "*"), []byte(`"arn:aws:s3:::mybucket/*"`), false}, + {NewResource("mybucket*", "myobject"), []byte(`"arn:aws:s3:::mybucket*/myobject"`), false}, + {NewResource("mybucket?0", "/2010/photos/*"), []byte(`"arn:aws:s3:::mybucket?0/2010/photos/*"`), false}, + {Resource{}, nil, true}, + } + + for i, testCase := range testCases { + result, err := json.Marshal(testCase.resource) + expectErr := (err != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + + if !testCase.expectErr { + if !reflect.DeepEqual(result, testCase.expectedResult) { + t.Fatalf("case %v: result: expected: %v, got: %v", i+1, string(testCase.expectedResult), string(result)) + } + } + } +} + +func TestResourceUnmarshalJSON(t *testing.T) { + testCases := []struct { + data []byte + expectedResult Resource + expectErr bool + }{ + {[]byte(`"arn:aws:s3:::*"`), NewResource("*", ""), false}, + {[]byte(`"arn:aws:s3:::mybucket*"`), NewResource("mybucket*", ""), false}, + {[]byte(`"arn:aws:s3:::mybucket"`), NewResource("mybucket", ""), false}, + {[]byte(`"arn:aws:s3:::*/*"`), NewResource("*", "*"), false}, + {[]byte(`"arn:aws:s3:::mybucket/*"`), NewResource("mybucket", "*"), false}, + {[]byte(`"arn:aws:s3:::mybucket*/myobject"`), NewResource("mybucket*", "myobject"), false}, + {[]byte(`"arn:aws:s3:::mybucket?0/2010/photos/*"`), NewResource("mybucket?0", "/2010/photos/*"), false}, + {[]byte(`"mybucket/myobject*"`), Resource{}, true}, + {[]byte(`"arn:aws:s3:::/*"`), Resource{}, true}, + } + + for i, testCase := range testCases { + var result Resource + err := json.Unmarshal(testCase.data, &result) + expectErr := (err != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + + if !testCase.expectErr { + if !reflect.DeepEqual(result, testCase.expectedResult) { + t.Fatalf("case %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result) + } + } + } +} + +func TestResourceValidate(t *testing.T) { + testCases := []struct { + resource Resource + expectErr bool + }{ + {NewResource("mybucket", "/myobject*"), false}, + {NewResource("", "/myobject*"), false}, + {NewResource("", ""), true}, + } + + for i, testCase := range testCases { + err := testCase.resource.Validate() + expectErr := (err != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + } +} diff --git a/pkg/iam/policy/resourceset.go b/pkg/iam/policy/resourceset.go new file mode 100644 index 000000000..99f700d6e --- /dev/null +++ b/pkg/iam/policy/resourceset.go @@ -0,0 +1,147 @@ +/* + * Minio Cloud Storage, (C) 2018 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 iampolicy + +import ( + "encoding/json" + "fmt" + "sort" + + "github.com/minio/minio-go/pkg/set" +) + +// ResourceSet - set of resources in policy statement. +type ResourceSet map[Resource]struct{} + +// bucketResourceExists - checks if at least one bucket resource exists in the set. +func (resourceSet ResourceSet) bucketResourceExists() bool { + for resource := range resourceSet { + if resource.isBucketPattern() { + return true + } + } + + return false +} + +// objectResourceExists - checks if at least one object resource exists in the set. +func (resourceSet ResourceSet) objectResourceExists() bool { + for resource := range resourceSet { + if resource.isObjectPattern() { + return true + } + } + + return false +} + +// Add - adds resource to resource set. +func (resourceSet ResourceSet) Add(resource Resource) { + resourceSet[resource] = struct{}{} +} + +// Intersection - returns resources available in both ResourceSet. +func (resourceSet ResourceSet) Intersection(sset ResourceSet) ResourceSet { + nset := NewResourceSet() + for k := range resourceSet { + if _, ok := sset[k]; ok { + nset.Add(k) + } + } + + return nset +} + +// MarshalJSON - encodes ResourceSet to JSON data. +func (resourceSet ResourceSet) MarshalJSON() ([]byte, error) { + if len(resourceSet) == 0 { + return nil, fmt.Errorf("empty resource set") + } + + resources := []Resource{} + for resource := range resourceSet { + resources = append(resources, resource) + } + + return json.Marshal(resources) +} + +// Match - matches object name with anyone of resource pattern in resource set. +func (resourceSet ResourceSet) Match(resource string) bool { + for r := range resourceSet { + if r.Match(resource) { + return true + } + } + + return false +} + +func (resourceSet ResourceSet) String() string { + resources := []string{} + for resource := range resourceSet { + resources = append(resources, resource.String()) + } + sort.Strings(resources) + + return fmt.Sprintf("%v", resources) +} + +// UnmarshalJSON - decodes JSON data to ResourceSet. +func (resourceSet *ResourceSet) UnmarshalJSON(data []byte) error { + var sset set.StringSet + if err := json.Unmarshal(data, &sset); err != nil { + return err + } + + *resourceSet = make(ResourceSet) + for _, s := range sset.ToSlice() { + resource, err := parseResource(s) + if err != nil { + return err + } + + if _, found := (*resourceSet)[resource]; found { + return fmt.Errorf("duplicate resource '%v' found", s) + } + + resourceSet.Add(resource) + } + + return nil +} + +// Validate - validates ResourceSet. +func (resourceSet ResourceSet) Validate() error { + for resource := range resourceSet { + if err := resource.Validate(); err != nil { + return err + } + } + + return nil +} + +// NewResourceSet - creates new resource set. +func NewResourceSet(resources ...Resource) ResourceSet { + resourceSet := make(ResourceSet) + for _, resource := range resources { + resourceSet.Add(resource) + } + + return resourceSet +} diff --git a/pkg/iam/policy/resourceset_test.go b/pkg/iam/policy/resourceset_test.go new file mode 100644 index 000000000..8e19e9ac7 --- /dev/null +++ b/pkg/iam/policy/resourceset_test.go @@ -0,0 +1,239 @@ +/* + * Minio Cloud Storage, (C) 2018 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 iampolicy + +import ( + "encoding/json" + "reflect" + "testing" +) + +func TestResourceSetBucketResourceExists(t *testing.T) { + testCases := []struct { + resourceSet ResourceSet + expectedResult bool + }{ + {NewResourceSet(NewResource("*", "")), true}, + {NewResourceSet(NewResource("mybucket", "")), true}, + {NewResourceSet(NewResource("mybucket*", "")), true}, + {NewResourceSet(NewResource("mybucket?0", "")), true}, + {NewResourceSet(NewResource("mybucket", "/2010/photos/*"), NewResource("mybucket", "")), true}, + {NewResourceSet(NewResource("", "*")), false}, + {NewResourceSet(NewResource("*", "*")), false}, + {NewResourceSet(NewResource("mybucket", "*")), false}, + {NewResourceSet(NewResource("mybucket*", "/myobject")), false}, + {NewResourceSet(NewResource("mybucket?0", "/2010/photos/*")), false}, + } + + for i, testCase := range testCases { + result := testCase.resourceSet.bucketResourceExists() + + if result != testCase.expectedResult { + t.Fatalf("case %v: expected: %v, got: %v", i+1, testCase.expectedResult, result) + } + } +} + +func TestResourceSetObjectResourceExists(t *testing.T) { + testCases := []struct { + resourceSet ResourceSet + expectedResult bool + }{ + {NewResourceSet(NewResource("*", "")), true}, + {NewResourceSet(NewResource("mybucket*", "")), true}, + {NewResourceSet(NewResource("", "*")), true}, + {NewResourceSet(NewResource("*", "*")), true}, + {NewResourceSet(NewResource("mybucket", "*")), true}, + {NewResourceSet(NewResource("mybucket*", "/myobject")), true}, + {NewResourceSet(NewResource("mybucket?0", "/2010/photos/*")), true}, + {NewResourceSet(NewResource("mybucket", ""), NewResource("mybucket", "/2910/photos/*")), true}, + {NewResourceSet(NewResource("mybucket", "")), false}, + {NewResourceSet(NewResource("mybucket?0", "")), false}, + } + + for i, testCase := range testCases { + result := testCase.resourceSet.objectResourceExists() + + if result != testCase.expectedResult { + t.Fatalf("case %v: expected: %v, got: %v", i+1, testCase.expectedResult, result) + } + } +} + +func TestResourceSetAdd(t *testing.T) { + testCases := []struct { + resourceSet ResourceSet + resource Resource + expectedResult ResourceSet + }{ + {NewResourceSet(), NewResource("mybucket", "/myobject*"), + NewResourceSet(NewResource("mybucket", "/myobject*"))}, + {NewResourceSet(NewResource("mybucket", "/myobject*")), + NewResource("mybucket", "/yourobject*"), + NewResourceSet(NewResource("mybucket", "/myobject*"), + NewResource("mybucket", "/yourobject*"))}, + {NewResourceSet(NewResource("mybucket", "/myobject*")), + NewResource("mybucket", "/myobject*"), + NewResourceSet(NewResource("mybucket", "/myobject*"))}, + } + + for i, testCase := range testCases { + testCase.resourceSet.Add(testCase.resource) + + if !reflect.DeepEqual(testCase.resourceSet, testCase.expectedResult) { + t.Fatalf("case %v: expected: %v, got: %v", i+1, testCase.expectedResult, testCase.resourceSet) + } + } +} + +func TestResourceSetIntersection(t *testing.T) { + testCases := []struct { + set ResourceSet + setToIntersect ResourceSet + expectedResult ResourceSet + }{ + {NewResourceSet(), NewResourceSet(NewResource("mybucket", "/myobject*")), NewResourceSet()}, + {NewResourceSet(NewResource("mybucket", "/myobject*")), NewResourceSet(), NewResourceSet()}, + {NewResourceSet(NewResource("mybucket", "/myobject*")), + NewResourceSet(NewResource("mybucket", "/myobject*"), NewResource("mybucket", "/yourobject*")), + NewResourceSet(NewResource("mybucket", "/myobject*"))}, + } + + for i, testCase := range testCases { + result := testCase.set.Intersection(testCase.setToIntersect) + + if !reflect.DeepEqual(result, testCase.expectedResult) { + t.Fatalf("case %v: expected: %v, got: %v\n", i+1, testCase.expectedResult, testCase.set) + } + } +} + +func TestResourceSetMarshalJSON(t *testing.T) { + testCases := []struct { + resoruceSet ResourceSet + expectedResult []byte + expectErr bool + }{ + {NewResourceSet(NewResource("mybucket", "/myobject*")), + []byte(`["arn:aws:s3:::mybucket/myobject*"]`), false}, + {NewResourceSet(NewResource("mybucket", "/photos/myobject*")), + []byte(`["arn:aws:s3:::mybucket/photos/myobject*"]`), false}, + {NewResourceSet(), nil, true}, + } + + for i, testCase := range testCases { + result, err := json.Marshal(testCase.resoruceSet) + expectErr := (err != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + + if !testCase.expectErr { + if !reflect.DeepEqual(result, testCase.expectedResult) { + t.Fatalf("case %v: result: expected: %v, got: %v", i+1, string(testCase.expectedResult), string(result)) + } + } + } +} + +func TestResourceSetMatch(t *testing.T) { + testCases := []struct { + resourceSet ResourceSet + resource string + expectedResult bool + }{ + {NewResourceSet(NewResource("*", "")), "mybucket", true}, + {NewResourceSet(NewResource("*", "")), "mybucket/myobject", true}, + {NewResourceSet(NewResource("mybucket*", "")), "mybucket", true}, + {NewResourceSet(NewResource("mybucket*", "")), "mybucket/myobject", true}, + {NewResourceSet(NewResource("", "*")), "/myobject", true}, + {NewResourceSet(NewResource("*", "*")), "mybucket/myobject", true}, + {NewResourceSet(NewResource("mybucket", "*")), "mybucket/myobject", true}, + {NewResourceSet(NewResource("mybucket*", "/myobject")), "mybucket/myobject", true}, + {NewResourceSet(NewResource("mybucket*", "/myobject")), "mybucket100/myobject", true}, + {NewResourceSet(NewResource("mybucket?0", "/2010/photos/*")), "mybucket20/2010/photos/1.jpg", true}, + {NewResourceSet(NewResource("mybucket", "")), "mybucket", true}, + {NewResourceSet(NewResource("mybucket?0", "")), "mybucket30", true}, + {NewResourceSet(NewResource("mybucket?0", "/2010/photos/*"), + NewResource("mybucket", "/2010/photos/*")), "mybucket/2010/photos/1.jpg", true}, + {NewResourceSet(NewResource("", "*")), "mybucket/myobject", false}, + {NewResourceSet(NewResource("*", "*")), "mybucket", false}, + {NewResourceSet(NewResource("mybucket", "*")), "mybucket10/myobject", false}, + {NewResourceSet(NewResource("mybucket", "")), "mybucket/myobject", true}, + {NewResourceSet(), "mybucket/myobject", false}, + } + + for i, testCase := range testCases { + result := testCase.resourceSet.Match(testCase.resource) + + if result != testCase.expectedResult { + t.Fatalf("case %v: expected: %v, got: %v", i+1, testCase.expectedResult, result) + } + } +} + +func TestResourceSetUnmarshalJSON(t *testing.T) { + testCases := []struct { + data []byte + expectedResult ResourceSet + expectErr bool + }{ + {[]byte(`"arn:aws:s3:::mybucket/myobject*"`), + NewResourceSet(NewResource("mybucket", "/myobject*")), false}, + {[]byte(`"arn:aws:s3:::mybucket/photos/myobject*"`), + NewResourceSet(NewResource("mybucket", "/photos/myobject*")), false}, + {[]byte(`"arn:aws:s3:::mybucket"`), NewResourceSet(NewResource("mybucket", "")), false}, + {[]byte(`"mybucket/myobject*"`), nil, true}, + } + + for i, testCase := range testCases { + var result ResourceSet + err := json.Unmarshal(testCase.data, &result) + expectErr := (err != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + + if !testCase.expectErr { + if !reflect.DeepEqual(result, testCase.expectedResult) { + t.Fatalf("case %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result) + } + } + } +} + +func TestResourceSetValidate(t *testing.T) { + testCases := []struct { + resourceSet ResourceSet + expectErr bool + }{ + {NewResourceSet(NewResource("mybucket", "/myobject*")), false}, + {NewResourceSet(NewResource("", "/myobject*")), false}, + {NewResourceSet(NewResource("", "")), true}, + } + + for i, testCase := range testCases { + err := testCase.resourceSet.Validate() + expectErr := (err != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + } +} diff --git a/pkg/iam/policy/statement.go b/pkg/iam/policy/statement.go new file mode 100644 index 000000000..8e0d1ec99 --- /dev/null +++ b/pkg/iam/policy/statement.go @@ -0,0 +1,141 @@ +/* + * Minio Cloud Storage, (C) 2018 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 iampolicy + +import ( + "encoding/json" + "fmt" + "strings" + + "github.com/minio/minio/pkg/policy" + "github.com/minio/minio/pkg/policy/condition" +) + +// Statement - iam policy statement. +type Statement struct { + SID policy.ID `json:"Sid,omitempty"` + Effect policy.Effect `json:"Effect"` + Actions ActionSet `json:"Action"` + Resources ResourceSet `json:"Resource"` + Conditions condition.Functions `json:"Condition,omitempty"` +} + +// IsAllowed - checks given policy args is allowed to continue the Rest API. +func (statement Statement) IsAllowed(args Args) bool { + check := func() bool { + if !statement.Actions.Match(args.Action) { + return false + } + + resource := args.BucketName + if args.ObjectName != "" { + if !strings.HasPrefix(args.ObjectName, "/") { + resource += "/" + } + + resource += args.ObjectName + } + + if !statement.Resources.Match(resource) { + return false + } + + return statement.Conditions.Evaluate(args.ConditionValues) + } + + return statement.Effect.IsAllowed(check()) +} + +// isValid - checks whether statement is valid or not. +func (statement Statement) isValid() error { + if !statement.Effect.IsValid() { + return fmt.Errorf("invalid Effect %v", statement.Effect) + } + + if len(statement.Actions) == 0 { + return fmt.Errorf("Action must not be empty") + } + + if len(statement.Resources) == 0 { + return fmt.Errorf("Resource must not be empty") + } + + if err := statement.Resources.Validate(); err != nil { + return err + } + + for action := range statement.Actions { + if !statement.Resources.objectResourceExists() && !statement.Resources.bucketResourceExists() { + return fmt.Errorf("unsupported Resource found %v for action %v", statement.Resources, action) + } + + keys := statement.Conditions.Keys() + keyDiff := keys.Difference(actionConditionKeyMap[action]) + if !keyDiff.IsEmpty() { + return fmt.Errorf("unsupported condition keys '%v' used for action '%v'", keyDiff, action) + } + } + + return nil +} + +// MarshalJSON - encodes JSON data to Statement. +func (statement Statement) MarshalJSON() ([]byte, error) { + if err := statement.isValid(); err != nil { + return nil, err + } + + // subtype to avoid recursive call to MarshalJSON() + type subStatement Statement + ss := subStatement(statement) + return json.Marshal(ss) +} + +// UnmarshalJSON - decodes JSON data to Statement. +func (statement *Statement) UnmarshalJSON(data []byte) error { + // subtype to avoid recursive call to UnmarshalJSON() + type subStatement Statement + var ss subStatement + + if err := json.Unmarshal(data, &ss); err != nil { + return err + } + + s := Statement(ss) + if err := s.isValid(); err != nil { + return err + } + + *statement = s + + return nil +} + +// Validate - validates Statement is for given bucket or not. +func (statement Statement) Validate() error { + return statement.isValid() +} + +// NewStatement - creates new statement. +func NewStatement(effect policy.Effect, actionSet ActionSet, resourceSet ResourceSet, conditions condition.Functions) Statement { + return Statement{ + Effect: effect, + Actions: actionSet, + Resources: resourceSet, + Conditions: conditions, + } +} diff --git a/pkg/iam/policy/statement_test.go b/pkg/iam/policy/statement_test.go new file mode 100644 index 000000000..d79f19315 --- /dev/null +++ b/pkg/iam/policy/statement_test.go @@ -0,0 +1,515 @@ +/* + * Minio Cloud Storage, (C) 2018 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 iampolicy + +import ( + "encoding/json" + "net" + "reflect" + "testing" + + "github.com/minio/minio/pkg/policy" + "github.com/minio/minio/pkg/policy/condition" +) + +func TestStatementIsAllowed(t *testing.T) { + case1Statement := NewStatement( + policy.Allow, + NewActionSet(GetBucketLocationAction, PutObjectAction), + NewResourceSet(NewResource("*", "")), + condition.NewFunctions(), + ) + + case2Statement := NewStatement( + policy.Allow, + NewActionSet(GetObjectAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ) + + _, IPNet1, err := net.ParseCIDR("192.168.1.0/24") + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + func1, err := condition.NewIPAddressFunc( + condition.AWSSourceIP, + IPNet1, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + + case3Statement := NewStatement( + policy.Allow, + NewActionSet(GetObjectAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(func1), + ) + + case4Statement := NewStatement( + policy.Deny, + NewActionSet(GetObjectAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(func1), + ) + + anonGetBucketLocationArgs := Args{ + AccountName: "Q3AM3UQ867SPQQA43P2F", + Action: GetBucketLocationAction, + BucketName: "mybucket", + ConditionValues: map[string][]string{}, + } + + anonPutObjectActionArgs := Args{ + AccountName: "Q3AM3UQ867SPQQA43P2F", + Action: PutObjectAction, + BucketName: "mybucket", + ConditionValues: map[string][]string{ + "x-amz-copy-source": {"mybucket/myobject"}, + "SourceIp": {"192.168.1.10"}, + }, + ObjectName: "myobject", + } + + anonGetObjectActionArgs := Args{ + AccountName: "Q3AM3UQ867SPQQA43P2F", + Action: GetObjectAction, + BucketName: "mybucket", + ConditionValues: map[string][]string{}, + ObjectName: "myobject", + } + + getBucketLocationArgs := Args{ + AccountName: "Q3AM3UQ867SPQQA43P2F", + Action: GetBucketLocationAction, + BucketName: "mybucket", + ConditionValues: map[string][]string{}, + } + + putObjectActionArgs := Args{ + AccountName: "Q3AM3UQ867SPQQA43P2F", + Action: PutObjectAction, + BucketName: "mybucket", + ConditionValues: map[string][]string{ + "x-amz-copy-source": {"mybucket/myobject"}, + "SourceIp": {"192.168.1.10"}, + }, + ObjectName: "myobject", + } + + getObjectActionArgs := Args{ + AccountName: "Q3AM3UQ867SPQQA43P2F", + Action: GetObjectAction, + BucketName: "mybucket", + ConditionValues: map[string][]string{}, + ObjectName: "myobject", + } + + testCases := []struct { + statement Statement + args Args + expectedResult bool + }{ + {case1Statement, anonGetBucketLocationArgs, true}, + {case1Statement, anonPutObjectActionArgs, true}, + {case1Statement, anonGetObjectActionArgs, false}, + {case1Statement, getBucketLocationArgs, true}, + {case1Statement, putObjectActionArgs, true}, + {case1Statement, getObjectActionArgs, false}, + + {case2Statement, anonGetBucketLocationArgs, false}, + {case2Statement, anonPutObjectActionArgs, true}, + {case2Statement, anonGetObjectActionArgs, true}, + {case2Statement, getBucketLocationArgs, false}, + {case2Statement, putObjectActionArgs, true}, + {case2Statement, getObjectActionArgs, true}, + + {case3Statement, anonGetBucketLocationArgs, false}, + {case3Statement, anonPutObjectActionArgs, true}, + {case3Statement, anonGetObjectActionArgs, false}, + {case3Statement, getBucketLocationArgs, false}, + {case3Statement, putObjectActionArgs, true}, + {case3Statement, getObjectActionArgs, false}, + + {case4Statement, anonGetBucketLocationArgs, true}, + {case4Statement, anonPutObjectActionArgs, false}, + {case4Statement, anonGetObjectActionArgs, true}, + {case4Statement, getBucketLocationArgs, true}, + {case4Statement, putObjectActionArgs, false}, + {case4Statement, getObjectActionArgs, true}, + } + + for i, testCase := range testCases { + result := testCase.statement.IsAllowed(testCase.args) + + if result != testCase.expectedResult { + t.Fatalf("case %v: expected: %v, got: %v\n", i+1, testCase.expectedResult, result) + } + } +} + +func TestStatementIsValid(t *testing.T) { + _, IPNet1, err := net.ParseCIDR("192.168.1.0/24") + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + func1, err := condition.NewIPAddressFunc( + condition.AWSSourceIP, + IPNet1, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + + func2, err := condition.NewStringEqualsFunc( + condition.S3XAmzCopySource, + "mybucket/myobject", + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + + testCases := []struct { + statement Statement + expectErr bool + }{ + // Invalid effect error. + {NewStatement( + policy.Effect("foo"), + NewActionSet(GetBucketLocationAction, PutObjectAction), + NewResourceSet(NewResource("*", "")), + condition.NewFunctions(), + ), true}, + // Empty actions error. + {NewStatement( + policy.Allow, + NewActionSet(), + NewResourceSet(NewResource("*", "")), + condition.NewFunctions(), + ), true}, + // Empty resources error. + {NewStatement( + policy.Allow, + NewActionSet(GetBucketLocationAction, PutObjectAction), + NewResourceSet(), + condition.NewFunctions(), + ), true}, + // Unsupported conditions for GetObject + {NewStatement( + policy.Allow, + NewActionSet(GetObjectAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "myobject*")), + condition.NewFunctions(func1, func2), + ), true}, + {NewStatement( + policy.Allow, + NewActionSet(GetBucketLocationAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "myobject*")), + condition.NewFunctions(), + ), false}, + {NewStatement( + policy.Allow, + NewActionSet(GetBucketLocationAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "")), + condition.NewFunctions(), + ), false}, + {NewStatement( + policy.Deny, + NewActionSet(GetObjectAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "myobject*")), + condition.NewFunctions(func1), + ), false}, + } + + for i, testCase := range testCases { + err := testCase.statement.isValid() + expectErr := (err != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + } +} + +func TestStatementMarshalJSON(t *testing.T) { + case1Statement := NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ) + case1Statement.SID = "SomeId1" + case1Data := []byte(`{"Sid":"SomeId1","Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::mybucket/myobject*"]}`) + + func1, err := condition.NewNullFunc( + condition.S3XAmzCopySource, + true, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + case2Statement := NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(func1), + ) + case2Data := []byte(`{"Effect":"Allow","Action":["s3:PutObject"],"Resource":["arn:aws:s3:::mybucket/myobject*"],"Condition":{"Null":{"s3:x-amz-copy-source":[true]}}}`) + + func2, err := condition.NewNullFunc( + condition.S3XAmzServerSideEncryption, + false, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + case3Statement := NewStatement( + policy.Deny, + NewActionSet(GetObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(func2), + ) + case3Data := []byte(`{"Effect":"Deny","Action":["s3:GetObject"],"Resource":["arn:aws:s3:::mybucket/myobject*"],"Condition":{"Null":{"s3:x-amz-server-side-encryption":[false]}}}`) + + case4Statement := NewStatement( + policy.Allow, + NewActionSet(GetObjectAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "myobject*")), + condition.NewFunctions(func1, func2), + ) + + testCases := []struct { + statement Statement + expectedResult []byte + expectErr bool + }{ + {case1Statement, case1Data, false}, + {case2Statement, case2Data, false}, + {case3Statement, case3Data, false}, + // Invalid statement error. + {case4Statement, nil, true}, + } + + for i, testCase := range testCases { + result, err := json.Marshal(testCase.statement) + expectErr := (err != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + + if !testCase.expectErr { + if !reflect.DeepEqual(result, testCase.expectedResult) { + t.Fatalf("case %v: result: expected: %v, got: %v", i+1, string(testCase.expectedResult), string(result)) + } + } + } +} + +func TestStatementUnmarshalJSON(t *testing.T) { + case1Data := []byte(`{ + "Sid": "SomeId1", + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*" +}`) + case1Statement := NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ) + case1Statement.SID = "SomeId1" + + case2Data := []byte(`{ + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*", + "Condition": { + "Null": { + "s3:x-amz-copy-source": true + } + } +}`) + func1, err := condition.NewNullFunc( + condition.S3XAmzCopySource, + true, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + case2Statement := NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(func1), + ) + + case3Data := []byte(`{ + "Effect": "Deny", + "Action": [ + "s3:PutObject", + "s3:GetObject" + ], + "Resource": "arn:aws:s3:::mybucket/myobject*", + "Condition": { + "Null": { + "s3:x-amz-server-side-encryption": "false" + } + } +}`) + func2, err := condition.NewNullFunc( + condition.S3XAmzServerSideEncryption, + false, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + case3Statement := NewStatement( + policy.Deny, + NewActionSet(PutObjectAction, GetObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(func2), + ) + + case4Data := []byte(`{ + "Effect": "Allow", + "Action": "s3:PutObjec", + "Resource": "arn:aws:s3:::mybucket/myobject*" +}`) + + case5Data := []byte(`{ + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*" +}`) + + case7Data := []byte(`{ + "Effect": "Allow", + "Resource": "arn:aws:s3:::mybucket/myobject*" +}`) + + case8Data := []byte(`{ + "Effect": "Allow", + "Action": "s3:PutObject" +}`) + + case9Data := []byte(`{ + "Effect": "Allow", + "Action": "s3:PutObject", + "Resource": "arn:aws:s3:::mybucket/myobject*", + "Condition": { + } +}`) + + case10Data := []byte(`{ + "Effect": "Deny", + "Action": [ + "s3:PutObject", + "s3:GetObject" + ], + "Resource": "arn:aws:s3:::mybucket/myobject*", + "Condition": { + "StringEquals": { + "s3:x-amz-copy-source": "yourbucket/myobject*" + } + } +}`) + + testCases := []struct { + data []byte + expectedResult Statement + expectErr bool + }{ + {case1Data, case1Statement, false}, + {case2Data, case2Statement, false}, + {case3Data, case3Statement, false}, + // JSON unmarshaling error. + {case4Data, Statement{}, true}, + // Invalid effect error. + {case5Data, Statement{}, true}, + // Empty action error. + {case7Data, Statement{}, true}, + // Empty resource error. + {case8Data, Statement{}, true}, + // Empty condition error. + {case9Data, Statement{}, true}, + // Unsupported condition key error. + {case10Data, Statement{}, true}, + } + + for i, testCase := range testCases { + var result Statement + expectErr := (json.Unmarshal(testCase.data, &result) != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + + if !testCase.expectErr { + if !reflect.DeepEqual(result, testCase.expectedResult) { + t.Fatalf("case %v: result: expected: %v, got: %v", i+1, testCase.expectedResult, result) + } + } + } +} + +func TestStatementValidate(t *testing.T) { + case1Statement := NewStatement( + policy.Allow, + NewActionSet(PutObjectAction), + NewResourceSet(NewResource("mybucket", "/myobject*")), + condition.NewFunctions(), + ) + + func1, err := condition.NewNullFunc( + condition.S3XAmzCopySource, + true, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + func2, err := condition.NewNullFunc( + condition.S3XAmzServerSideEncryption, + false, + ) + if err != nil { + t.Fatalf("unexpected error. %v\n", err) + } + case2Statement := NewStatement( + policy.Allow, + NewActionSet(GetObjectAction, PutObjectAction), + NewResourceSet(NewResource("mybucket", "myobject*")), + condition.NewFunctions(func1, func2), + ) + + testCases := []struct { + statement Statement + expectErr bool + }{ + {case1Statement, false}, + {case2Statement, true}, + } + + for i, testCase := range testCases { + err := testCase.statement.Validate() + expectErr := (err != nil) + + if expectErr != testCase.expectErr { + t.Fatalf("case %v: error: expected: %v, got: %v", i+1, testCase.expectErr, expectErr) + } + } +} diff --git a/pkg/iam/validator/jwks.go b/pkg/iam/validator/jwks.go new file mode 100644 index 000000000..1f88a1e24 --- /dev/null +++ b/pkg/iam/validator/jwks.go @@ -0,0 +1,137 @@ +/* + * Minio Cloud Storage, (C) 2018 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 validator + +import ( + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/base64" + "encoding/binary" + "errors" + "fmt" + "math/big" + "strings" +) + +// JWKS - https://tools.ietf.org/html/rfc7517 +type JWKS struct { + Keys []*JWKS `json:"keys,omitempty"` + + Kty string `json:"kty"` + Use string `json:"use,omitempty"` + Kid string `json:"kid,omitempty"` + Alg string `json:"alg,omitempty"` + + Crv string `json:"crv,omitempty"` + X string `json:"x,omitempty"` + Y string `json:"y,omitempty"` + D string `json:"d,omitempty"` + N string `json:"n,omitempty"` + E string `json:"e,omitempty"` + K string `json:"k,omitempty"` +} + +func safeDecode(str string) ([]byte, error) { + lenMod4 := len(str) % 4 + if lenMod4 > 0 { + str = str + strings.Repeat("=", 4-lenMod4) + } + + return base64.URLEncoding.DecodeString(str) +} + +var ( + errMalformedJWKRSAKey = errors.New("malformed JWK RSA key") + errMalformedJWKECKey = errors.New("malformed JWK EC key") +) + +// DecodePublicKey - decodes JSON Web Key (JWK) as public key +func (key *JWKS) DecodePublicKey() (crypto.PublicKey, error) { + switch key.Kty { + case "RSA": + if key.N == "" || key.E == "" { + return nil, errMalformedJWKRSAKey + } + + // decode exponent + data, err := safeDecode(key.E) + if err != nil { + return nil, errMalformedJWKRSAKey + } + + if len(data) < 4 { + ndata := make([]byte, 4) + copy(ndata[4-len(data):], data) + data = ndata + } + + pubKey := &rsa.PublicKey{ + N: &big.Int{}, + E: int(binary.BigEndian.Uint32(data[:])), + } + + data, err = safeDecode(key.N) + if err != nil { + return nil, errMalformedJWKRSAKey + } + pubKey.N.SetBytes(data) + + return pubKey, nil + case "EC": + if key.Crv == "" || key.X == "" || key.Y == "" { + return nil, errMalformedJWKECKey + } + + var curve elliptic.Curve + switch key.Crv { + case "P-224": + curve = elliptic.P224() + case "P-256": + curve = elliptic.P256() + case "P-384": + curve = elliptic.P384() + case "P-521": + curve = elliptic.P521() + default: + return nil, fmt.Errorf("Unknown curve type: %s", key.Crv) + } + + pubKey := &ecdsa.PublicKey{ + Curve: curve, + X: &big.Int{}, + Y: &big.Int{}, + } + + data, err := safeDecode(key.X) + if err != nil { + return nil, errMalformedJWKECKey + } + pubKey.X.SetBytes(data) + + data, err = safeDecode(key.Y) + if err != nil { + return nil, errMalformedJWKECKey + } + pubKey.Y.SetBytes(data) + + return pubKey, nil + default: + return nil, fmt.Errorf("Unknown JWK key type %s", key.Kty) + } +} diff --git a/pkg/iam/validator/jwks_test.go b/pkg/iam/validator/jwks_test.go new file mode 100644 index 000000000..36aa705b2 --- /dev/null +++ b/pkg/iam/validator/jwks_test.go @@ -0,0 +1,103 @@ +/* + * Minio Cloud Storage, (C) 2018 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 validator + +import ( + "bytes" + "crypto" + "crypto/ecdsa" + "crypto/elliptic" + "crypto/rsa" + "encoding/json" + "testing" +) + +// A.1 - Example public keys +func TestPublicKey(t *testing.T) { + const jsonkey = `{"keys": + [ + {"kty":"EC", + "crv":"P-256", + "x":"MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4", + "y":"4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM", + "use":"enc", + "kid":"1"}, + + {"kty":"RSA", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e":"AQAB", + "alg":"RS256", + "kid":"2011-04-29"} + ] + }` + + var jk JWKS + if err := json.Unmarshal([]byte(jsonkey), &jk); err != nil { + t.Fatal("Unmarshal: ", err) + } else if len(jk.Keys) != 2 { + t.Fatalf("Expected 2 keys, got %d", len(jk.Keys)) + } + + keys := make([]crypto.PublicKey, len(jk.Keys)) + for ii, jks := range jk.Keys { + var err error + keys[ii], err = jks.DecodePublicKey() + if err != nil { + t.Fatalf("Failed to decode key %d: %v", ii, err) + } + } + + if key0, ok := keys[0].(*ecdsa.PublicKey); !ok { + t.Fatalf("Expected ECDSA key[0], got %T", keys[0]) + } else if key1, ok := keys[1].(*rsa.PublicKey); !ok { + t.Fatalf("Expected RSA key[1], got %T", keys[1]) + } else if key0.Curve != elliptic.P256() { + t.Fatal("Key[0] is not using P-256 curve") + } else if !bytes.Equal(key0.X.Bytes(), []byte{0x30, 0xa0, 0x42, 0x4c, 0xd2, + 0x1c, 0x29, 0x44, 0x83, 0x8a, 0x2d, 0x75, 0xc9, 0x2b, 0x37, 0xe7, 0x6e, 0xa2, + 0xd, 0x9f, 0x0, 0x89, 0x3a, 0x3b, 0x4e, 0xee, 0x8a, 0x3c, 0xa, 0xaf, 0xec, 0x3e}) { + t.Fatalf("Bad key[0].X, got %v", key0.X.Bytes()) + } else if !bytes.Equal(key0.Y.Bytes(), []byte{0xe0, 0x4b, 0x65, 0xe9, 0x24, + 0x56, 0xd9, 0x88, 0x8b, 0x52, 0xb3, 0x79, 0xbd, 0xfb, 0xd5, 0x1e, 0xe8, + 0x69, 0xef, 0x1f, 0xf, 0xc6, 0x5b, 0x66, 0x59, 0x69, 0x5b, 0x6c, 0xce, + 0x8, 0x17, 0x23}) { + t.Fatalf("Bad key[0].Y, got %v", key0.Y.Bytes()) + } else if key1.E != 0x10001 { + t.Fatalf("Bad key[1].E: %d", key1.E) + } else if !bytes.Equal(key1.N.Bytes(), []byte{0xd2, 0xfc, 0x7b, 0x6a, 0xa, 0x1e, + 0x6c, 0x67, 0x10, 0x4a, 0xeb, 0x8f, 0x88, 0xb2, 0x57, 0x66, 0x9b, 0x4d, 0xf6, + 0x79, 0xdd, 0xad, 0x9, 0x9b, 0x5c, 0x4a, 0x6c, 0xd9, 0xa8, 0x80, 0x15, 0xb5, + 0xa1, 0x33, 0xbf, 0xb, 0x85, 0x6c, 0x78, 0x71, 0xb6, 0xdf, 0x0, 0xb, 0x55, + 0x4f, 0xce, 0xb3, 0xc2, 0xed, 0x51, 0x2b, 0xb6, 0x8f, 0x14, 0x5c, 0x6e, 0x84, + 0x34, 0x75, 0x2f, 0xab, 0x52, 0xa1, 0xcf, 0xc1, 0x24, 0x40, 0x8f, 0x79, 0xb5, + 0x8a, 0x45, 0x78, 0xc1, 0x64, 0x28, 0x85, 0x57, 0x89, 0xf7, 0xa2, 0x49, 0xe3, + 0x84, 0xcb, 0x2d, 0x9f, 0xae, 0x2d, 0x67, 0xfd, 0x96, 0xfb, 0x92, 0x6c, 0x19, + 0x8e, 0x7, 0x73, 0x99, 0xfd, 0xc8, 0x15, 0xc0, 0xaf, 0x9, 0x7d, 0xde, 0x5a, + 0xad, 0xef, 0xf4, 0x4d, 0xe7, 0xe, 0x82, 0x7f, 0x48, 0x78, 0x43, 0x24, 0x39, + 0xbf, 0xee, 0xb9, 0x60, 0x68, 0xd0, 0x47, 0x4f, 0xc5, 0xd, 0x6d, 0x90, 0xbf, + 0x3a, 0x98, 0xdf, 0xaf, 0x10, 0x40, 0xc8, 0x9c, 0x2, 0xd6, 0x92, 0xab, 0x3b, + 0x3c, 0x28, 0x96, 0x60, 0x9d, 0x86, 0xfd, 0x73, 0xb7, 0x74, 0xce, 0x7, 0x40, + 0x64, 0x7c, 0xee, 0xea, 0xa3, 0x10, 0xbd, 0x12, 0xf9, 0x85, 0xa8, 0xeb, 0x9f, + 0x59, 0xfd, 0xd4, 0x26, 0xce, 0xa5, 0xb2, 0x12, 0xf, 0x4f, 0x2a, 0x34, 0xbc, + 0xab, 0x76, 0x4b, 0x7e, 0x6c, 0x54, 0xd6, 0x84, 0x2, 0x38, 0xbc, 0xc4, 0x5, 0x87, + 0xa5, 0x9e, 0x66, 0xed, 0x1f, 0x33, 0x89, 0x45, 0x77, 0x63, 0x5c, 0x47, 0xa, + 0xf7, 0x5c, 0xf9, 0x2c, 0x20, 0xd1, 0xda, 0x43, 0xe1, 0xbf, 0xc4, 0x19, 0xe2, + 0x22, 0xa6, 0xf0, 0xd0, 0xbb, 0x35, 0x8c, 0x5e, 0x38, 0xf9, 0xcb, 0x5, 0xa, 0xea, + 0xfe, 0x90, 0x48, 0x14, 0xf1, 0xac, 0x1a, 0xa4, 0x9c, 0xca, 0x9e, 0xa0, 0xca, 0x83}) { + t.Fatalf("Bad key[1].N, got %v", key1.N.Bytes()) + } +} diff --git a/pkg/iam/validator/jwt.go b/pkg/iam/validator/jwt.go new file mode 100644 index 000000000..1d0001467 --- /dev/null +++ b/pkg/iam/validator/jwt.go @@ -0,0 +1,228 @@ +/* + * Minio Cloud Storage, (C) 2018 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 validator + +import ( + "crypto" + "crypto/tls" + "encoding/json" + "errors" + "fmt" + "net" + "net/http" + "os" + "strconv" + "time" + + jwtgo "github.com/dgrijalva/jwt-go" + xnet "github.com/minio/minio/pkg/net" +) + +// JWKSArgs - RSA authentication target arguments +type JWKSArgs struct { + URL *xnet.URL `json:"url"` + publicKey crypto.PublicKey +} + +// Validate JWT authentication target arguments +func (r *JWKSArgs) Validate() error { + return nil +} + +// PopulatePublicKey - populates a new publickey from the JWKS URL. +func (r *JWKSArgs) PopulatePublicKey() error { + insecureClient := &http.Client{Transport: newCustomHTTPTransport(true)} + client := &http.Client{Transport: newCustomHTTPTransport(false)} + resp, err := client.Get(r.URL.String()) + if err != nil { + resp, err = insecureClient.Get(r.URL.String()) + if err != nil { + return err + } + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + return errors.New(resp.Status) + } + + var jwk JWKS + if err = json.NewDecoder(resp.Body).Decode(&jwk); err != nil { + return err + } + + r.publicKey, err = jwk.Keys[0].DecodePublicKey() + if err != nil { + return err + } + + return nil +} + +// UnmarshalJSON - decodes JSON data. +func (r *JWKSArgs) UnmarshalJSON(data []byte) error { + // subtype to avoid recursive call to UnmarshalJSON() + type subJWKSArgs JWKSArgs + var sr subJWKSArgs + + // IAM related envs. + if jwksURL, ok := os.LookupEnv("MINIO_IAM_JWKS_URL"); ok { + u, err := xnet.ParseURL(jwksURL) + if err != nil { + return err + } + sr.URL = u + } else { + if err := json.Unmarshal(data, &sr); err != nil { + return err + } + } + + ar := JWKSArgs(sr) + if ar.URL == nil || ar.URL.String() == "" { + *r = ar + return nil + } + if err := ar.Validate(); err != nil { + return err + } + + if err := ar.PopulatePublicKey(); err != nil { + return err + } + + *r = ar + return nil +} + +// JWT - rs client grants provider details. +type JWT struct { + args JWKSArgs +} + +func expToInt64(expI interface{}) (expAt int64, err error) { + switch exp := expI.(type) { + case float64: + expAt = int64(exp) + case int64: + expAt = exp + case json.Number: + expAt, err = exp.Int64() + if err != nil { + return 0, err + } + default: + return 0, errors.New("invalid expiry value") + } + return expAt, nil +} + +func getDefaultExpiration(dsecs string) (time.Duration, error) { + defaultExpiryDuration := time.Duration(60) * time.Minute // Defaults to 1hr. + if dsecs != "" { + expirySecs, err := strconv.ParseInt(dsecs, 10, 64) + if err != nil { + return 0, err + } + // The duration, in seconds, of the role session. + // The value can range from 900 seconds (15 minutes) + // to 12 hours. + if expirySecs < 900 || expirySecs > 43200 { + return 0, errors.New("out of range value for duration in seconds") + } + + defaultExpiryDuration = time.Duration(expirySecs) * time.Second + } + return defaultExpiryDuration, nil +} + +// newCustomHTTPTransport returns a new http configuration +// used while communicating with the cloud backends. +// This sets the value for MaxIdleConnsPerHost from 2 (go default) +// to 100. +func newCustomHTTPTransport(insecure bool) *http.Transport { + return &http.Transport{ + Proxy: http.ProxyFromEnvironment, + DialContext: (&net.Dialer{ + Timeout: 30 * time.Second, + KeepAlive: 30 * time.Second, + }).DialContext, + MaxIdleConns: 1024, + MaxIdleConnsPerHost: 1024, + IdleConnTimeout: 30 * time.Second, + TLSHandshakeTimeout: 10 * time.Second, + ExpectContinueTimeout: 1 * time.Second, + TLSClientConfig: &tls.Config{InsecureSkipVerify: insecure}, + DisableCompression: true, + } +} + +// Validate - validates the access token. +func (p *JWT) Validate(token, dsecs string) (map[string]interface{}, error) { + keyFuncCallback := func(jwtToken *jwtgo.Token) (interface{}, error) { + if _, ok := jwtToken.Method.(*jwtgo.SigningMethodRSA); !ok { + if _, ok = jwtToken.Method.(*jwtgo.SigningMethodECDSA); ok { + return p.args.publicKey, nil + } + return nil, fmt.Errorf("Unexpected signing method: %v", jwtToken.Header["alg"]) + } + return p.args.publicKey, nil + } + + var claims jwtgo.MapClaims + jwtToken, err := jwtgo.ParseWithClaims(token, &claims, keyFuncCallback) + if err != nil { + return nil, err + } + + if !jwtToken.Valid { + return nil, fmt.Errorf("Invalid token: %v", token) + } + + expAt, err := expToInt64(claims["exp"]) + if err != nil { + return nil, err + } + + defaultExpiryDuration, err := getDefaultExpiration(dsecs) + if err != nil { + return nil, err + } + + if time.Unix(expAt, 0).UTC().Sub(time.Now().UTC()) < defaultExpiryDuration { + defaultExpiryDuration = time.Unix(expAt, 0).UTC().Sub(time.Now().UTC()) + } + + expiry := time.Now().UTC().Add(defaultExpiryDuration).Unix() + if expAt < expiry { + claims["exp"] = strconv.FormatInt(expAt, 64) + } + + return claims, nil + +} + +// ID returns the provider name and authentication type. +func (p *JWT) ID() ID { + return "jwt" +} + +// NewJWT - initialize new jwt authenticator. +func NewJWT(args JWKSArgs) *JWT { + return &JWT{ + args: args, + } +} diff --git a/pkg/iam/validator/jwt_test.go b/pkg/iam/validator/jwt_test.go new file mode 100644 index 000000000..8d54f6da6 --- /dev/null +++ b/pkg/iam/validator/jwt_test.go @@ -0,0 +1,120 @@ +/* + * Minio Cloud Storage, (C) 2018 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 validator + +import ( + "crypto" + "encoding/json" + "net/url" + "testing" + "time" + + xnet "github.com/minio/minio/pkg/net" +) + +func TestJWT(t *testing.T) { + const jsonkey = `{"keys": + [ + {"kty":"RSA", + "n": "0vx7agoebGcQSuuPiLJXZptN9nndrQmbXEps2aiAFbWhM78LhWx4cbbfAAtVT86zwu1RK7aPFFxuhDR1L6tSoc_BJECPebWKRXjBZCiFV4n3oknjhMstn64tZ_2W-5JsGY4Hc5n9yBXArwl93lqt7_RN5w6Cf0h4QyQ5v-65YGjQR0_FDW2QvzqY368QQMicAtaSqzs8KJZgnYb9c7d0zgdAZHzu6qMQvRL5hajrn1n91CbOpbISD08qNLyrdkt-bFTWhAI4vMQFh6WeZu0fM4lFd2NcRwr3XPksINHaQ-G_xBniIqbw0Ls1jF44-csFCur-kEgU8awapJzKnqDKgw", + "e":"AQAB", + "alg":"RS256", + "kid":"2011-04-29"} + ] + }` + + var jk JWKS + if err := json.Unmarshal([]byte(jsonkey), &jk); err != nil { + t.Fatal("Unmarshal: ", err) + } else if len(jk.Keys) != 1 { + t.Fatalf("Expected 1 keys, got %d", len(jk.Keys)) + } + + keys := make([]crypto.PublicKey, len(jk.Keys)) + for ii, jks := range jk.Keys { + var err error + keys[ii], err = jks.DecodePublicKey() + if err != nil { + t.Fatalf("Failed to decode key %d: %v", ii, err) + } + } + + u1, err := xnet.ParseURL("http://localhost:8443") + if err != nil { + t.Fatal(err) + } + + jwt := NewJWT(JWKSArgs{ + URL: u1, + publicKey: keys[0], + }) + if jwt.ID() != "jwt" { + t.Fatalf("Uexpected id %s for the validator", jwt.ID()) + } + + u, err := url.Parse("http://localhost:8443/?Token=invalid") + if err != nil { + t.Fatal(err) + } + if _, err := jwt.Validate(u.Query().Get("Token"), ""); err == nil { + t.Fatal(err) + } +} + +func TestDefaultExpiryDuration(t *testing.T) { + testCases := []struct { + reqURL string + duration time.Duration + expectErr bool + }{ + { + reqURL: "http://localhost:8443/?Token=xxxxx", + duration: time.Duration(60) * time.Minute, + }, + { + reqURL: "http://localhost:8443/?DurationSeconds=9s", + expectErr: true, + }, + { + reqURL: "http://localhost:8443/?DurationSeconds=43201", + expectErr: true, + }, + { + reqURL: "http://localhost:8443/?DurationSeconds=800", + expectErr: true, + }, + { + reqURL: "http://localhost:8443/?DurationSeconds=901", + duration: time.Duration(901) * time.Second, + }, + } + + for i, testCase := range testCases { + u, err := url.Parse(testCase.reqURL) + if err != nil { + t.Fatal(err) + } + d, err := getDefaultExpiration(u.Query().Get("DurationSeconds")) + gotErr := (err != nil) + if testCase.expectErr != gotErr { + t.Errorf("Test %d: Expected %v, got %v with error %s", i+1, testCase.expectErr, gotErr, err) + } + if d != testCase.duration { + t.Errorf("Test %d: Expected duration %d, got %d", i+1, testCase.duration, d) + } + } +} diff --git a/pkg/iam/validator/validators.go b/pkg/iam/validator/validators.go new file mode 100644 index 000000000..33de63a54 --- /dev/null +++ b/pkg/iam/validator/validators.go @@ -0,0 +1,92 @@ +/* + * Minio Cloud Storage, (C) 2018 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 validator + +import ( + "errors" + "fmt" + "sync" +) + +// ID - holds identification name authentication validator target. +type ID string + +// Validator interface describes basic implementation +// requirements of various authentication providers. +type Validator interface { + // Validate is a custom validator function for this provider, + // each validation is authenticationType or provider specific. + Validate(token string, duration string) (map[string]interface{}, error) + + // ID returns provider name of this provider. + ID() ID +} + +// ErrTokenExpired - error token expired +var ( + ErrTokenExpired = errors.New("token expired") + ErrInvalidDuration = errors.New("duration higher than token expiry") +) + +// Validators - holds list of providers indexed by provider id. +type Validators struct { + sync.RWMutex + providers map[ID]Validator +} + +// Add - adds unique provider to provider list. +func (list *Validators) Add(provider Validator) error { + list.Lock() + defer list.Unlock() + + if _, ok := list.providers[provider.ID()]; ok { + return fmt.Errorf("provider %v already exists", provider.ID()) + } + + list.providers[provider.ID()] = provider + return nil +} + +// List - returns available provider IDs. +func (list *Validators) List() []ID { + list.RLock() + defer list.RUnlock() + + keys := []ID{} + for k := range list.providers { + keys = append(keys, k) + } + + return keys +} + +// Get - returns the provider for the given providerID, if not found +// returns an error. +func (list *Validators) Get(id ID) (p Validator, err error) { + list.RLock() + defer list.RUnlock() + var ok bool + if p, ok = list.providers[id]; !ok { + return nil, fmt.Errorf("provider %v doesn't exist", id) + } + return p, nil +} + +// NewValidators - creates Validators. +func NewValidators() *Validators { + return &Validators{providers: make(map[ID]Validator)} +} diff --git a/pkg/iam/validator/validators_test.go b/pkg/iam/validator/validators_test.go new file mode 100644 index 000000000..cbb8a02db --- /dev/null +++ b/pkg/iam/validator/validators_test.go @@ -0,0 +1,64 @@ +/* + * Minio Cloud Storage, (C) 2018 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 validator + +import ( + "testing" +) + +type errorValidator struct{} + +func (e errorValidator) Validate(token, dsecs string) (map[string]interface{}, error) { + return nil, ErrTokenExpired +} + +func (e errorValidator) ID() ID { + return "err" +} + +func TestValidators(t *testing.T) { + vrs := NewValidators() + if err := vrs.Add(&errorValidator{}); err != nil { + t.Fatal(err) + } + + if err := vrs.Add(&errorValidator{}); err == nil { + t.Fatal("Unexpected should return error for double inserts") + } + + if _, err := vrs.Get("unknown"); err == nil { + t.Fatal("Unexpected should return error for unknown validators") + } + + v, err := vrs.Get("err") + if err != nil { + t.Fatal(err) + } + + if _, err = v.Validate("", ""); err != ErrTokenExpired { + t.Fatalf("Expected error %s, got %s", ErrTokenExpired, err) + } + + vids := vrs.List() + if len(vids) == 0 || len(vids) > 1 { + t.Fatalf("Unexpected number of vids %v", vids) + } + + if vids[0] != "err" { + t.Fatalf("Unexpected vid %v", vids[0]) + } +} diff --git a/pkg/madmin/API.md b/pkg/madmin/API.md index 23aa8e989..47f024e7f 100644 --- a/pkg/madmin/API.md +++ b/pkg/madmin/API.md @@ -36,10 +36,10 @@ func main() { ``` -| Service operations | Info operations | Healing operations | Config operations | Misc | -|:----------------------------|:----------------------------|:--------------------------------------|:--------------------------|:------------------------------------| -| [`ServiceStatus`](#ServiceStatus) | [`ServerInfo`](#ServerInfo) | [`Heal`](#Heal) | [`GetConfig`](#GetConfig) | [`SetCredentials`](#SetCredentials) | -| [`ServiceSendAction`](#ServiceSendAction) | | | [`SetConfig`](#SetConfig) | [`StartProfiling`](#StartProfiling) | +| Service operations | Info operations | Healing operations | Config operations | IAM operations | Misc | +|:----------------------------|:----------------------------|:--------------------------------------|:--------------------------|:------------------------------------|:------------------------------------| +| [`ServiceStatus`](#ServiceStatus) | [`ServerInfo`](#ServerInfo) | [`Heal`](#Heal) | [`GetConfig`](#GetConfig) | [`AddUser()`](#AddUser) | [`SetAdminCredentials`](#SetAdminCredentials) | +| [`ServiceSendAction`](#ServiceSendAction) | | | [`SetConfig`](#SetConfig) | [`AddUserPolicy`](#AddUserPolicy) | [`StartProfiling`](#StartProfiling) | | | | | [`GetConfigKeys`](#GetConfigKeys) | [`DownloadProfilingData`](#DownloadProfilingData) | | | | | [`SetConfigKeys`](#SetConfigKeys) | | @@ -273,7 +273,7 @@ __Example__ ### GetConfig() ([]byte, error) -Get config.json of a minio setup. +Get current `config.json` of a Minio server. __Example__ @@ -295,37 +295,17 @@ __Example__ -### SetConfig(config io.Reader) (SetConfigResult, error) -Set config.json of a minio setup and restart setup for configuration -change to take effect. - - -| Param | Type | Description | -|---|---|---| -|`st.Status` | _bool_ | true if set-config succeeded, false otherwise. | -|`st.NodeSummary.Name` | _string_ | Network address of the node. | -|`st.NodeSummary.ErrSet` | _bool_ | Bool representation indicating if an error is encountered with the node.| -|`st.NodeSummary.ErrMsg` | _string_ | String representation of the error (if any) on the node.| - +### SetConfig(config io.Reader) error +Set a new `config.json` for a Minio server. __Example__ ``` go config := bytes.NewReader([]byte(`config.json contents go here`)) - result, err := madmClnt.SetConfig(config) - if err != nil { + if err := madmClnt.SetConfig(config); err != nil { log.Fatalf("failed due to: %v", err) } - - var buf bytes.Buffer - enc := json.NewEncoder(&buf) - enc.SetEscapeHTML(false) - enc.SetIndent("", "\t") - err = enc.Encode(result) - if err != nil { - log.Fatalln(err) - } - log.Println("SetConfig: ", string(buf.Bytes())) + log.Println("SetConfig was successful") ``` @@ -367,18 +347,44 @@ __Example__ log.Println("New configuration successfully set") ``` +## 8. IAM operations + + +### AddUser(user string, secret string) error +Add a new user on a Minio server. + +__Example__ +``` go + if err = madmClnt.AddUser("newuser", "newstrongpassword"); err != nil { + log.Fatalln(err) + } +``` + + +### AddUserPolicy(user string, policy string) error +Set a new policy for a given user on Minio server. + +__Example__ + +``` go + policy := `{"Version": "2012-10-17","Statement": [{"Action": ["s3:GetObject"],"Effect": "Allow","Resource": ["arn:aws:s3:::my-bucketname/*"],"Sid": ""}]}` + + if err = madmClnt.AddUserPolicy("newuser", policy); err != nil { + log.Fatalln(err) + } +``` -## 8. Misc operations +## 9. Misc operations - -### SetCredentials() error + +### SetAdminCredentials() error Set new credentials of a Minio setup. __Example__ ``` go - err = madmClnt.SetCredentials("YOUR-NEW-ACCESSKEY", "YOUR-NEW-SECRETKEY") + err = madmClnt.SetAdminCredentials("YOUR-NEW-ACCESSKEY", "YOUR-NEW-SECRETKEY") if err != nil { log.Fatalln(err) } diff --git a/pkg/madmin/config-commands.go b/pkg/madmin/config-commands.go index b896ff78a..46427fe37 100644 --- a/pkg/madmin/config-commands.go +++ b/pkg/madmin/config-commands.go @@ -19,61 +19,16 @@ package madmin import ( "bytes" - "crypto/rand" "encoding/base64" "encoding/json" "errors" - "fmt" "io" - "io/ioutil" "net/http" "net/url" "github.com/minio/minio/pkg/quick" - "github.com/minio/sio" - "golang.org/x/crypto/argon2" ) -// EncryptServerConfigData - encrypts server config data. -func EncryptServerConfigData(password string, data []byte) ([]byte, error) { - salt := make([]byte, 32) - if _, err := io.ReadFull(rand.Reader, salt); err != nil { - return nil, err - } - - // derive an encryption key from the master key and the nonce - var key [32]byte - copy(key[:], argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)) - - encrypted, err := sio.EncryptReader(bytes.NewReader(data), sio.Config{ - Key: key[:]}, - ) - if err != nil { - return nil, err - } - edata, err := ioutil.ReadAll(encrypted) - return append(salt, edata...), err -} - -// DecryptServerConfigData - decrypts server config data. -func DecryptServerConfigData(password string, data io.Reader) ([]byte, error) { - salt := make([]byte, 32) - if _, err := io.ReadFull(data, salt); err != nil { - return nil, err - } - // derive an encryption key from the master key and the nonce - var key [32]byte - copy(key[:], argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)) - - decrypted, err := sio.DecryptReader(data, sio.Config{ - Key: key[:]}, - ) - if err != nil { - return nil, err - } - return ioutil.ReadAll(decrypted) -} - // GetConfig - returns the config.json of a minio setup, incoming data is encrypted. func (adm *AdminClient) GetConfig() ([]byte, error) { // Execute GET on /minio/admin/v1/config to get config of a setup. @@ -89,7 +44,7 @@ func (adm *AdminClient) GetConfig() ([]byte, error) { } defer closeResponse(resp) - return DecryptServerConfigData(adm.secretAccessKey, resp.Body) + return DecryptData(adm.secretAccessKey, resp.Body) } // GetConfigKeys - returns partial json or json value from config.json of a minio setup. @@ -114,7 +69,7 @@ func (adm *AdminClient) GetConfigKeys(keys []string) ([]byte, error) { return nil, httpRespToErrorResponse(resp) } - return DecryptServerConfigData(adm.secretAccessKey, resp.Body) + return DecryptData(adm.secretAccessKey, resp.Body) } // SetConfig - set config supplied as config.json for the setup. @@ -125,7 +80,7 @@ func (adm *AdminClient) SetConfig(config io.Reader) (err error) { configBuf := make([]byte, maxConfigJSONSize+1) n, err := io.ReadFull(config, configBuf) if err == nil { - return fmt.Errorf("too large file") + return bytes.ErrTooLarge } if err != io.ErrUnexpectedEOF { return err @@ -151,7 +106,7 @@ func (adm *AdminClient) SetConfig(config io.Reader) (err error) { return errors.New("Duplicate key in json file: " + err.Error()) } - econfigBytes, err := EncryptServerConfigData(adm.secretAccessKey, configBytes) + econfigBytes, err := EncryptData(adm.secretAccessKey, configBytes) if err != nil { return err } @@ -180,7 +135,7 @@ func (adm *AdminClient) SetConfig(config io.Reader) (err error) { func (adm *AdminClient) SetConfigKeys(params map[string]string) error { queryVals := make(url.Values) for k, v := range params { - encryptedVal, err := EncryptServerConfigData(adm.secretAccessKey, []byte(v)) + encryptedVal, err := EncryptData(adm.secretAccessKey, []byte(v)) if err != nil { return err } diff --git a/pkg/madmin/encrypt.go b/pkg/madmin/encrypt.go new file mode 100644 index 000000000..1b5960196 --- /dev/null +++ b/pkg/madmin/encrypt.go @@ -0,0 +1,68 @@ +/* + * Minio Cloud Storage, (C) 2018 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 madmin + +import ( + "bytes" + "crypto/rand" + "io" + "io/ioutil" + + "github.com/minio/sio" + "golang.org/x/crypto/argon2" +) + +// EncryptData - encrypts server config data. +func EncryptData(password string, data []byte) ([]byte, error) { + salt := make([]byte, 32) + if _, err := io.ReadFull(rand.Reader, salt); err != nil { + return nil, err + } + + // derive an encryption key from the master key and the nonce + var key [32]byte + copy(key[:], argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)) + + encrypted, err := sio.EncryptReader(bytes.NewReader(data), sio.Config{ + Key: key[:]}, + ) + if err != nil { + return nil, err + } + edata, err := ioutil.ReadAll(encrypted) + return append(salt, edata...), err +} + +// DecryptData - decrypts server config data. +func DecryptData(password string, data io.Reader) ([]byte, error) { + salt := make([]byte, 32) + if _, err := io.ReadFull(data, salt); err != nil { + return nil, err + } + // derive an encryption key from the master key and the nonce + var key [32]byte + copy(key[:], argon2.IDKey([]byte(password), salt, 1, 64*1024, 4, 32)) + + decrypted, err := sio.DecryptReader(data, sio.Config{ + Key: key[:]}, + ) + if err != nil { + return nil, err + } + return ioutil.ReadAll(decrypted) +} diff --git a/pkg/madmin/examples/add-user-and-policy.go b/pkg/madmin/examples/add-user-and-policy.go new file mode 100644 index 000000000..be0e50891 --- /dev/null +++ b/pkg/madmin/examples/add-user-and-policy.go @@ -0,0 +1,52 @@ +// +build ignore + +/* + * Minio Cloud Storage, (C) 2017 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 ( + "log" + + "github.com/minio/minio/pkg/madmin" +) + +func main() { + // Note: YOUR-ACCESSKEYID, YOUR-SECRETACCESSKEY are + // dummy values, please replace them with original values. + + // Note: YOUR-ACCESSKEYID, YOUR-SECRETACCESSKEY are + // dummy values, please replace them with original values. + + // API requests are secure (HTTPS) if secure=true and insecure (HTTPS) otherwise. + // New returns an Minio Admin client object. + madmClnt, err := madmin.New("your-minio.example.com:9000", "YOUR-ACCESSKEYID", "YOUR-SECRETACCESSKEY", true) + if err != nil { + log.Fatalln(err) + } + + if err = madmClnt.AddUser("newuser", "newstrongpassword"); err != nil { + log.Fatalln(err) + } + + // Create policy + policy := `{"Version": "2012-10-17","Statement": [{"Action": ["s3:GetObject"],"Effect": "Allow","Resource": ["arn:aws:s3:::my-bucketname/*"],"Sid": ""}]}` + + if err = madmClnt.AddUserPolicy("newuser", policy); err != nil { + log.Fatalln(err) + } +} diff --git a/pkg/madmin/examples/set-credentials.go b/pkg/madmin/examples/set-credentials.go index 35e81b958..ffbc55a04 100644 --- a/pkg/madmin/examples/set-credentials.go +++ b/pkg/madmin/examples/set-credentials.go @@ -36,7 +36,7 @@ func main() { log.Fatalln(err) } - err = madmClnt.SetCredentials("YOUR-NEW-ACCESSKEY", "YOUR-NEW-SECRETKEY") + err = madmClnt.SetAdminCredentials("YOUR-NEW-ACCESSKEY", "YOUR-NEW-SECRETKEY") if err != nil { log.Fatalln(err) } diff --git a/pkg/madmin/generic-commands.go b/pkg/madmin/generic-commands.go index 5843355ca..86ba781bf 100644 --- a/pkg/madmin/generic-commands.go +++ b/pkg/madmin/generic-commands.go @@ -28,16 +28,16 @@ type SetCredsReq struct { SecretKey string `json:"secretKey"` } -// SetCredentials - Call Set Credentials API to set new access and +// SetAdminCredentials - Call Set Credentials API to set new access and // secret keys in the specified Minio server -func (adm *AdminClient) SetCredentials(access, secret string) error { +func (adm *AdminClient) SetAdminCredentials(access, secret string) error { // Setup request's body body, err := json.Marshal(SetCredsReq{access, secret}) if err != nil { return err } - ebody, err := EncryptServerConfigData(adm.secretAccessKey, body) + ebody, err := EncryptData(adm.secretAccessKey, body) if err != nil { return err } diff --git a/pkg/madmin/user-commands.go b/pkg/madmin/user-commands.go new file mode 100644 index 000000000..2ab513cb4 --- /dev/null +++ b/pkg/madmin/user-commands.go @@ -0,0 +1,158 @@ +/* + * Minio Cloud Storage, (C) 2018 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 madmin + +import ( + "encoding/json" + "net/http" + "net/url" +) + +// AccountStatus - account status. +type AccountStatus string + +// Account status per user. +const ( + AccountEnabled AccountStatus = "enabled" + AccountDisabled AccountStatus = "disabled" +) + +// UserInfo carries information about long term users. +type UserInfo struct { + SecretKey string `json:"secretKey"` + Status AccountStatus `json:"status"` +} + +// RemoveUser - remove a user. +func (adm *AdminClient) RemoveUser(accessKey string) error { + queryValues := url.Values{} + queryValues.Set("accessKey", accessKey) + + reqData := requestData{ + relPath: "/v1/remove-user", + queryValues: queryValues, + } + + // Execute DELETE on /minio/admin/v1/remove-user to remove a user. + resp, err := adm.executeMethod("DELETE", reqData) + + defer closeResponse(resp) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return httpRespToErrorResponse(resp) + } + + return nil +} + +// SetUser - sets a user info. +func (adm *AdminClient) SetUser(accessKey, secretKey string, status AccountStatus) error { + data, err := json.Marshal(UserInfo{ + SecretKey: secretKey, + Status: status, + }) + if err != nil { + return err + } + econfigBytes, err := EncryptData(adm.secretAccessKey, data) + if err != nil { + return err + } + + queryValues := url.Values{} + queryValues.Set("accessKey", accessKey) + + reqData := requestData{ + relPath: "/v1/add-user", + queryValues: queryValues, + content: econfigBytes, + } + + // Execute PUT on /minio/admin/v1/add-user to set a user. + resp, err := adm.executeMethod("PUT", reqData) + + defer closeResponse(resp) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return httpRespToErrorResponse(resp) + } + + return nil +} + +// AddUser - adds a user. +func (adm *AdminClient) AddUser(accessKey, secretKey string) error { + return adm.SetUser(accessKey, secretKey, AccountEnabled) +} + +// RemoveUserPolicy - remove a policy for a user. +func (adm *AdminClient) RemoveUserPolicy(accessKey string) error { + queryValues := url.Values{} + queryValues.Set("accessKey", accessKey) + + reqData := requestData{ + relPath: "/v1/remove-user-policy", + queryValues: queryValues, + } + + // Execute DELETE on /minio/admin/v1/remove-user-policy to remove policy. + resp, err := adm.executeMethod("DELETE", reqData) + + defer closeResponse(resp) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return httpRespToErrorResponse(resp) + } + + return nil +} + +// AddUserPolicy - adds a policy for a user. +func (adm *AdminClient) AddUserPolicy(accessKey, policy string) error { + queryValues := url.Values{} + queryValues.Set("accessKey", accessKey) + + reqData := requestData{ + relPath: "/v1/add-user-policy", + queryValues: queryValues, + content: []byte(policy), + } + + // Execute PUT on /minio/admin/v1/add-user-policy to set policy. + resp, err := adm.executeMethod("PUT", reqData) + + defer closeResponse(resp) + if err != nil { + return err + } + + if resp.StatusCode != http.StatusOK { + return httpRespToErrorResponse(resp) + } + + return nil +} diff --git a/pkg/policy/policy.go b/pkg/policy/policy.go index 77a712190..fabaa7db3 100644 --- a/pkg/policy/policy.go +++ b/pkg/policy/policy.go @@ -27,12 +27,12 @@ const DefaultVersion = "2012-10-17" // Args - arguments to policy to check whether it is allowed type Args struct { - AccountName string - Action Action - BucketName string - ConditionValues map[string][]string - IsOwner bool - ObjectName string + AccountName string `json:"account"` + Action Action `json:"action"` + BucketName string `json:"bucket"` + ConditionValues map[string][]string `json:"conditions"` + IsOwner bool `json:"owner"` + ObjectName string `json:"object"` } // Policy - bucket policy.