package api import ( "encoding/json" "encoding/xml" "fmt" "io" "net/http" "net/http/httptest" "net/url" "testing" "time" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/data" apiErrors "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/errors" s3middleware "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/api/middleware" "git.frostfs.info/TrueCloudLab/frostfs-s3-gw/metrics" engineiam "git.frostfs.info/TrueCloudLab/policy-engine/iam" "git.frostfs.info/TrueCloudLab/policy-engine/pkg/chain" "git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine" "git.frostfs.info/TrueCloudLab/policy-engine/pkg/engine/inmemory" "git.frostfs.info/TrueCloudLab/policy-engine/schema/s3" "github.com/go-chi/chi/v5" "github.com/go-chi/chi/v5/middleware" "github.com/prometheus/client_golang/prometheus" "github.com/stretchr/testify/require" "go.uber.org/zap/zaptest" ) type routerMock struct { t *testing.T router *chi.Mux cfg Config middlewareSettings *middlewareSettingsMock policyChecker engine.LocalOverrideEngine } func (m *routerMock) ServeHTTP(w http.ResponseWriter, r *http.Request) { m.router.ServeHTTP(w, r) } func prepareRouter(t *testing.T) *routerMock { middlewareSettings := &middlewareSettingsMock{} policyChecker := inmemory.NewInMemoryLocalOverrides() logger := zaptest.NewLogger(t) metricsConfig := metrics.AppMetricsConfig{ Logger: logger, PoolStatistics: &poolStatisticMock{}, Registerer: prometheus.NewRegistry(), Enabled: true, } cfg := Config{ Throttle: middleware.ThrottleOpts{ Limit: 10, BacklogTimeout: 30 * time.Second, }, Handler: &handlerMock{t: t, cfg: middlewareSettings, buckets: map[string]*data.BucketInfo{}}, Center: ¢erMock{t: t}, Log: logger, Metrics: metrics.NewAppMetrics(metricsConfig), MiddlewareSettings: middlewareSettings, PolicyChecker: policyChecker, Domains: []string{"domain1", "domain2"}, FrostfsID: &frostFSIDMock{}, } return &routerMock{ t: t, router: NewRouter(cfg), cfg: cfg, middlewareSettings: middlewareSettings, policyChecker: policyChecker, } } func TestRouterUploadPart(t *testing.T) { chiRouter := prepareRouter(t) createBucket(chiRouter, "", "dkirillov") w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodPut, "/dkirillov/fix-object", nil) query := make(url.Values) query.Set("uploadId", "some-id") query.Set("partNumber", "1") r.URL.RawQuery = query.Encode() chiRouter.ServeHTTP(w, r) resp := readResponse(t, w) require.Equal(t, "UploadPart", resp.Method) } func TestRouterListMultipartUploads(t *testing.T) { chiRouter := prepareRouter(t) createBucket(chiRouter, "", "test-bucket") w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/test-bucket", nil) query := make(url.Values) query.Set("uploads", "") r.URL.RawQuery = query.Encode() chiRouter.ServeHTTP(w, r) resp := readResponse(t, w) require.Equal(t, "ListMultipartUploads", resp.Method) } func TestRouterObjectWithSlashes(t *testing.T) { chiRouter := prepareRouter(t) ns, bktName, objName := "", "dkirillov", "/fix/object" createBucket(chiRouter, ns, bktName) resp := putObject(chiRouter, ns, bktName, objName) require.Equal(t, objName, resp.ReqInfo.ObjectName) } func TestRouterObjectEscaping(t *testing.T) { chiRouter := prepareRouter(t) ns, bktName := "", "dkirillov" createBucket(chiRouter, ns, bktName) for _, tc := range []struct { name string expectedObjName string objName string }{ { name: "simple", expectedObjName: "object", objName: "object", }, { name: "with slashes", expectedObjName: "fix/object", objName: "fix/object", }, { name: "with slash escaped", expectedObjName: "/foo/bar", objName: "/foo%2fbar", }, { name: "with percentage escaped", expectedObjName: "fix/object%ac", objName: "fix/object%25ac", }, { name: "with awful mint name", expectedObjName: "äöüex ®©µÄÆÐÕæŒƕƩDž 01000000 0x40 \u0040 amȡȹɆple&0a!-_.*'()&$@=;:+,?<>.pdf", objName: "%C3%A4%C3%B6%C3%BCex%20%C2%AE%C2%A9%C2%B5%C3%84%C3%86%C3%90%C3%95%C3%A6%C5%92%C6%95%C6%A9%C7%85%2001000000%200x40%20%40%20am%C8%A1%C8%B9%C9%86ple%260a%21-_.%2A%27%28%29%26%24%40%3D%3B%3A%2B%2C%3F%3C%3E.pdf", }, } { t.Run(tc.name, func(t *testing.T) { resp := putObject(chiRouter, ns, bktName, tc.objName) require.Equal(t, tc.expectedObjName, resp.ReqInfo.ObjectName) }) } } func TestPolicyChecker(t *testing.T) { chiRouter := prepareRouter(t) ns1, bktName1, objName1 := "", "bucket", "object" ns2, bktName2, objName2 := "custom-ns", "other-bucket", "object" createBucket(chiRouter, ns1, bktName1) createBucket(chiRouter, ns2, bktName1) createBucket(chiRouter, ns2, bktName2) ruleChain := &chain.Chain{ ID: chain.ID("id"), Rules: []chain.Rule{{ Status: chain.AccessDenied, Actions: chain.Actions{Names: []string{"*"}}, Resources: chain.Resources{Names: []string{fmt.Sprintf(s3.ResourceFormatS3BucketObjects, bktName1)}}, }}, } _, _, err := chiRouter.policyChecker.MorphRuleChainStorage().AddMorphRuleChain(chain.S3, engine.NamespaceTarget(ns2), ruleChain) require.NoError(t, err) // check we can access 'bucket' in default namespace putObject(chiRouter, ns1, bktName1, objName1) // check we can access 'other-bucket' in custom namespace putObject(chiRouter, ns2, bktName2, objName2) // check we cannot access 'bucket' in custom namespace putObjectErr(chiRouter, ns2, bktName1, objName2, apiErrors.ErrAccessDenied) } func TestPolicyCheckerReqTypeDetermination(t *testing.T) { chiRouter := prepareRouter(t) bktName, objName := "bucket", "object" policy := engineiam.Policy{ Statement: []engineiam.Statement{{ Principal: map[engineiam.PrincipalType][]string{engineiam.Wildcard: {}}, Effect: engineiam.AllowEffect, Action: engineiam.Action{"s3:*"}, Resource: engineiam.Resource{fmt.Sprintf(s3.ResourceFormatS3All)}, }}, } ruleChain, err := engineiam.ConvertToS3Chain(policy, nil) require.NoError(t, err) _, _, err = chiRouter.policyChecker.MorphRuleChainStorage().AddMorphRuleChain(chain.S3, engine.NamespaceTarget(""), ruleChain) require.NoError(t, err) chiRouter.middlewareSettings.denyByDefault = true t.Run("can list buckets", func(t *testing.T) { w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil) chiRouter.ServeHTTP(w, r) resp := readResponse(t, w) require.Equal(t, s3middleware.ListBucketsOperation, resp.Method) }) t.Run("can head 'bucket'", func(t *testing.T) { w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodHead, "/"+bktName, nil) chiRouter.ServeHTTP(w, r) resp := readResponse(t, w) require.Equal(t, s3middleware.HeadBucketOperation, resp.Method) }) t.Run("can put object into 'bucket'", func(t *testing.T) { w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, fmt.Sprintf("/%s/%s", bktName, objName), nil) chiRouter.ServeHTTP(w, r) resp := readResponse(t, w) require.Equal(t, s3middleware.PutObjectOperation, resp.Method) }) } func TestDefaultBehaviorPolicyChecker(t *testing.T) { chiRouter := prepareRouter(t) ns, bktName := "", "bucket" // check we can access bucket if rules not found createBucket(chiRouter, ns, bktName) // check we cannot access if rules not found when settings is enabled chiRouter.middlewareSettings.denyByDefault = true createBucketErr(chiRouter, ns, bktName, apiErrors.ErrAccessDenied) } func TestACLAPE(t *testing.T) { t.Run("acl disabled, ape deny by default", func(t *testing.T) { router := prepareRouter(t) ns, bktName, objName := "", "bucket", "object" bktNameOld, bktNameNew := "old-bucket", "new-bucket" createOldBucket(router, bktNameOld) createNewBucket(router, bktNameNew) router.middlewareSettings.aclEnabled = false router.middlewareSettings.denyByDefault = true // Allow because of using old bucket putObject(router, ns, bktNameOld, objName) // Deny because of deny by default putObjectErr(router, ns, bktNameNew, objName, apiErrors.ErrAccessDenied) // Deny because of deny by default createBucketErr(router, ns, bktName, apiErrors.ErrAccessDenied) listBucketsErr(router, ns, apiErrors.ErrAccessDenied) // Allow operations and check allowOperations(router, ns, []string{"s3:CreateBucket", "s3:ListBuckets"}) createBucket(router, ns, bktName) listBuckets(router, ns) }) t.Run("acl disabled, ape allow by default", func(t *testing.T) { router := prepareRouter(t) ns, bktName, objName := "", "bucket", "object" bktNameOld, bktNameNew := "old-bucket", "new-bucket" createOldBucket(router, bktNameOld) createNewBucket(router, bktNameNew) router.middlewareSettings.aclEnabled = false router.middlewareSettings.denyByDefault = false // Allow because of using old bucket putObject(router, ns, bktNameOld, objName) // Allow because of allow by default putObject(router, ns, bktNameNew, objName) // Allow because of deny by default createBucket(router, ns, bktName) listBuckets(router, ns) // Deny operations and check denyOperations(router, ns, []string{"s3:CreateBucket", "s3:ListBuckets"}) createBucketErr(router, ns, bktName, apiErrors.ErrAccessDenied) listBucketsErr(router, ns, apiErrors.ErrAccessDenied) }) t.Run("acl enabled, ape deny by default", func(t *testing.T) { router := prepareRouter(t) ns, bktName, objName := "", "bucket", "object" bktNameOld, bktNameNew := "old-bucket", "new-bucket" createOldBucket(router, bktNameOld) createNewBucket(router, bktNameNew) router.middlewareSettings.aclEnabled = true router.middlewareSettings.denyByDefault = true // Allow because of using old bucket putObject(router, ns, bktNameOld, objName) // Deny because of deny by default putObjectErr(router, ns, bktNameNew, objName, apiErrors.ErrAccessDenied) // Allow because of old behavior createBucket(router, ns, bktName) listBuckets(router, ns) }) t.Run("acl enabled, ape allow by default", func(t *testing.T) { router := prepareRouter(t) ns, bktName, objName := "", "bucket", "object" bktNameOld, bktNameNew := "old-bucket", "new-bucket" createOldBucket(router, bktNameOld) createNewBucket(router, bktNameNew) router.middlewareSettings.aclEnabled = true router.middlewareSettings.denyByDefault = false // Allow because of using old bucket putObject(router, ns, bktNameOld, objName) // Allow because of allow by default putObject(router, ns, bktNameNew, objName) // Allow because of old behavior createBucket(router, ns, bktName) listBuckets(router, ns) }) } func allowOperations(router *routerMock, ns string, operations []string) { addPolicy(router, ns, "allow", engineiam.AllowEffect, operations) } func denyOperations(router *routerMock, ns string, operations []string) { addPolicy(router, ns, "deny", engineiam.DenyEffect, operations) } func addPolicy(router *routerMock, ns string, id string, effect engineiam.Effect, operations []string) { policy := engineiam.Policy{ Statement: []engineiam.Statement{{ Principal: map[engineiam.PrincipalType][]string{engineiam.Wildcard: {}}, Effect: effect, Action: engineiam.Action(operations), Resource: engineiam.Resource{fmt.Sprintf(s3.ResourceFormatS3All)}, }}, } ruleChain, err := engineiam.ConvertToS3Chain(policy, nil) require.NoError(router.t, err) ruleChain.ID = chain.ID(id) _, _, err = router.policyChecker.MorphRuleChainStorage().AddMorphRuleChain(chain.S3, engine.NamespaceTarget(ns), ruleChain) require.NoError(router.t, err) } func createOldBucket(router *routerMock, bktName string) { createSpecificBucket(router, bktName, true) } func createNewBucket(router *routerMock, bktName string) { createSpecificBucket(router, bktName, false) } func createSpecificBucket(router *routerMock, bktName string, old bool) { aclEnabled := router.middlewareSettings.ACLEnabled() router.middlewareSettings.aclEnabled = old createBucket(router, "", bktName) router.middlewareSettings.aclEnabled = aclEnabled } func createBucket(router *routerMock, namespace, bktName string) { w := createBucketBase(router, namespace, bktName) resp := readResponse(router.t, w) require.Equal(router.t, s3middleware.CreateBucketOperation, resp.Method) } func createBucketErr(router *routerMock, namespace, bktName string, errCode apiErrors.ErrorCode) { w := createBucketBase(router, namespace, bktName) assertAPIError(router.t, w, errCode) } func createBucketBase(router *routerMock, namespace, bktName string) *httptest.ResponseRecorder { w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, "/"+bktName, nil) r.Header.Set(FrostfsNamespaceHeader, namespace) router.ServeHTTP(w, r) return w } func listBuckets(router *routerMock, namespace string) { w := listBucketsBase(router, namespace) resp := readResponse(router.t, w) require.Equal(router.t, s3middleware.ListBucketsOperation, resp.Method) } func listBucketsErr(router *routerMock, namespace string, errCode apiErrors.ErrorCode) { w := listBucketsBase(router, namespace) assertAPIError(router.t, w, errCode) } func listBucketsBase(router *routerMock, namespace string) *httptest.ResponseRecorder { w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodGet, "/", nil) r.Header.Set(FrostfsNamespaceHeader, namespace) router.ServeHTTP(w, r) return w } func putObject(router *routerMock, namespace, bktName, objName string) handlerResult { w := putObjectBase(router, namespace, bktName, objName) resp := readResponse(router.t, w) require.Equal(router.t, s3middleware.PutObjectOperation, resp.Method) return resp } func putObjectErr(router *routerMock, namespace, bktName, objName string, errCode apiErrors.ErrorCode) { w := putObjectBase(router, namespace, bktName, objName) assertAPIError(router.t, w, errCode) } func putObjectBase(router *routerMock, namespace, bktName, objName string) *httptest.ResponseRecorder { w, r := httptest.NewRecorder(), httptest.NewRequest(http.MethodPut, "/"+bktName+"/"+objName, nil) r.Header.Set(FrostfsNamespaceHeader, namespace) router.ServeHTTP(w, r) return w } func TestOwnerIDRetrieving(t *testing.T) { chiRouter := prepareRouter(t) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodGet, "/test-bucket", nil) chiRouter.ServeHTTP(w, r) resp := readResponse(t, w) require.NotEqual(t, "anon", resp.ReqInfo.User) w = httptest.NewRecorder() r = httptest.NewRequest(http.MethodGet, "/test-bucket", nil) chiRouter.cfg.Center.(*centerMock).anon = true chiRouter.ServeHTTP(w, r) resp = readResponse(t, w) require.Equal(t, "anon", resp.ReqInfo.User) } func TestBillingMetrics(t *testing.T) { chiRouter := prepareRouter(t) bktName, objName := "test-bucket", "test-object" target := fmt.Sprintf("/%s/%s", bktName, objName) w := httptest.NewRecorder() r := httptest.NewRequest(http.MethodPut, target, nil) chiRouter.ServeHTTP(w, r) dump := chiRouter.cfg.Metrics.UsersAPIStats().DumpMetrics() require.Len(t, dump.Requests, 1) require.NotEqual(t, "anon", dump.Requests[0].User) require.Equal(t, metrics.PUTRequest, dump.Requests[0].Operation) require.Equal(t, bktName, dump.Requests[0].Bucket) require.Equal(t, 1, dump.Requests[0].Requests) chiRouter.cfg.Center.(*centerMock).anon = true w = httptest.NewRecorder() r = httptest.NewRequest(http.MethodPut, target, nil) chiRouter.ServeHTTP(w, r) dump = chiRouter.cfg.Metrics.UsersAPIStats().DumpMetrics() require.Len(t, dump.Requests, 1) require.Equal(t, "anon", dump.Requests[0].User) } func readResponse(t *testing.T, w *httptest.ResponseRecorder) handlerResult { var res handlerResult resData, err := io.ReadAll(w.Result().Body) require.NoError(t, err) err = json.Unmarshal(resData, &res) require.NoErrorf(t, err, "actual body: '%s'", string(resData)) return res } func assertAPIError(t *testing.T, w *httptest.ResponseRecorder, expectedErrorCode apiErrors.ErrorCode) { actualErrorResponse := &s3middleware.ErrorResponse{} err := xml.NewDecoder(w.Result().Body).Decode(actualErrorResponse) require.NoError(t, err) expectedError := apiErrors.GetAPIError(expectedErrorCode) require.Equal(t, expectedError.HTTPStatusCode, w.Code) require.Equal(t, expectedError.Code, actualErrorResponse.Code) if expectedError.ErrCode != apiErrors.ErrInternalError { require.Contains(t, actualErrorResponse.Message, expectedError.Description) } }