diff --git a/backend/backend.go b/backend/backend.go index e46baab..88c7832 100644 --- a/backend/backend.go +++ b/backend/backend.go @@ -70,6 +70,7 @@ type Backend interface { // non AWS actions ChangeBucketOwner(_ context.Context, bucket, newOwner string) error + ListBucketsAndOwners(context.Context) ([]s3response.Bucket, error) } type BackendUnsupported struct{} @@ -178,3 +179,6 @@ func (BackendUnsupported) RemoveTags(_ context.Context, bucket, object string) e func (BackendUnsupported) ChangeBucketOwner(_ context.Context, bucket, newOwner string) error { return s3err.GetAPIError(s3err.ErrNotImplemented) } +func (BackendUnsupported) ListBucketsAndOwners(context.Context) ([]s3response.Bucket, error) { + return []s3response.Bucket{}, s3err.GetAPIError(s3err.ErrNotImplemented) +} diff --git a/backend/posix/posix.go b/backend/posix/posix.go index 4111840..c2a006c 100644 --- a/backend/posix/posix.go +++ b/backend/posix/posix.go @@ -1563,6 +1563,46 @@ func (p *Posix) ChangeBucketOwner(ctx context.Context, bucket, newOwner string) return nil } +func (p *Posix) ListBucketsAndOwners(ctx context.Context) (buckets []s3response.Bucket, err error) { + entries, err := os.ReadDir(".") + if err != nil { + return buckets, fmt.Errorf("readdir buckets: %w", err) + } + + for _, entry := range entries { + if !entry.IsDir() { + continue + } + + fi, err := entry.Info() + if err != nil { + continue + } + + aclTag, err := xattr.Get(entry.Name(), aclkey) + if err != nil { + return buckets, fmt.Errorf("get acl tag: %w", err) + } + + var acl auth.ACL + err = json.Unmarshal(aclTag, &acl) + if err != nil { + return buckets, fmt.Errorf("parse acl tag: %w", err) + } + + buckets = append(buckets, s3response.Bucket{ + Name: fi.Name(), + Owner: acl.Owner, + }) + } + + sort.SliceStable(buckets, func(i, j int) bool { + return buckets[i].Name < buckets[j].Name + }) + + return buckets, nil +} + const ( iamMode = 0600 ) diff --git a/cmd/versitygw/admin.go b/cmd/versitygw/admin.go index 8d91393..fdffa7d 100644 --- a/cmd/versitygw/admin.go +++ b/cmd/versitygw/admin.go @@ -29,6 +29,7 @@ import ( v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/urfave/cli/v2" "github.com/versity/versitygw/auth" + "github.com/versity/versitygw/s3response" ) var ( @@ -105,6 +106,11 @@ func adminCommand() *cli.Command { }, Action: changeBucketOwner, }, + { + Name: "list-buckets", + Usage: "Lists all the gateway buckets and owners.", + Action: listBuckets, + }, }, Flags: []cli.Flag{ // TODO: create a configuration file for this @@ -319,3 +325,60 @@ func changeBucketOwner(ctx *cli.Context) error { return nil } + +func printBuckets(buckets []s3response.Bucket) { + w := new(tabwriter.Writer) + w.Init(os.Stdout, minwidth, tabwidth, padding, padchar, flags) + fmt.Fprintln(w, "Bucket\tOwner") + fmt.Fprintln(w, "-------\t----") + for _, acc := range buckets { + fmt.Fprintf(w, "%v\t%v\n", acc.Name, acc.Owner) + } + fmt.Fprintln(w) + w.Flush() +} + +func listBuckets(ctx *cli.Context) error { + req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%v/list-buckets", adminEndpoint), nil) + if err != nil { + return fmt.Errorf("failed to send the request: %w", err) + } + + signer := v4.NewSigner() + + hashedPayload := sha256.Sum256([]byte{}) + hexPayload := hex.EncodeToString(hashedPayload[:]) + + req.Header.Set("X-Amz-Content-Sha256", hexPayload) + + signErr := signer.SignHTTP(req.Context(), aws.Credentials{AccessKeyID: adminAccess, SecretAccessKey: adminSecret}, req, hexPayload, "s3", region, time.Now()) + if signErr != nil { + return fmt.Errorf("failed to sign the request: %w", err) + } + + client := http.Client{} + + resp, err := client.Do(req) + if err != nil { + return fmt.Errorf("failed to send the request: %w", err) + } + + body, err := io.ReadAll(resp.Body) + if err != nil { + return err + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + return fmt.Errorf(string(body)) + } + + var buckets []s3response.Bucket + if err := json.Unmarshal(body, &buckets); err != nil { + return err + } + + printBuckets(buckets) + + return nil +} diff --git a/s3api/controllers/admin.go b/s3api/controllers/admin.go index 63c3654..483f606 100644 --- a/s3api/controllers/admin.go +++ b/s3api/controllers/admin.go @@ -103,3 +103,17 @@ func (c AdminController) ChangeBucketOwner(ctx *fiber.Ctx) error { return ctx.Status(201).SendString("Bucket owner has been updated successfully") } + +func (c AdminController) ListBuckets(ctx *fiber.Ctx) error { + role := ctx.Locals("role").(string) + if role != "admin" { + return fmt.Errorf("access denied: only admin users have access to this resource") + } + + buckets, err := c.be.ListBucketsAndOwners(ctx.Context()) + if err != nil { + return err + } + + return ctx.JSON(buckets) +} diff --git a/s3api/controllers/admin_test.go b/s3api/controllers/admin_test.go index b71d7b6..fc918a8 100644 --- a/s3api/controllers/admin_test.go +++ b/s3api/controllers/admin_test.go @@ -23,6 +23,7 @@ import ( "github.com/gofiber/fiber/v2" "github.com/versity/versitygw/auth" + "github.com/versity/versitygw/s3response" ) func TestAdminController_CreateUser(t *testing.T) { @@ -395,3 +396,72 @@ func TestAdminController_ChangeBucketOwner(t *testing.T) { } } } + +func TestAdminController_ListBuckets(t *testing.T) { + type args struct { + req *http.Request + } + adminController := AdminController{ + be: &BackendMock{ + ListBucketsAndOwnersFunc: func(contextMoqParam context.Context) ([]s3response.Bucket, error) { + return []s3response.Bucket{}, nil + }, + }, + } + + app := fiber.New() + + app.Use(func(ctx *fiber.Ctx) error { + ctx.Locals("role", "admin") + return ctx.Next() + }) + + app.Patch("/list-buckets", adminController.ListBuckets) + + appRoleErr := fiber.New() + + appRoleErr.Use(func(ctx *fiber.Ctx) error { + ctx.Locals("role", "user") + return ctx.Next() + }) + + appRoleErr.Patch("/list-buckets", adminController.ListBuckets) + + tests := []struct { + name string + app *fiber.App + args args + wantErr bool + statusCode int + }{ + { + name: "List-buckets-incorrect-role", + app: appRoleErr, + args: args{ + req: httptest.NewRequest(http.MethodPatch, "/list-buckets", nil), + }, + wantErr: false, + statusCode: 500, + }, + { + name: "List-buckets-success", + app: app, + args: args{ + req: httptest.NewRequest(http.MethodPatch, "/list-buckets", nil), + }, + wantErr: false, + statusCode: 200, + }, + } + for _, tt := range tests { + resp, err := tt.app.Test(tt.args.req) + + if (err != nil) != tt.wantErr { + t.Errorf("AdminController.ListBuckets() error = %v, wantErr %v", err, tt.wantErr) + } + + if resp.StatusCode != tt.statusCode { + t.Errorf("AdminController.ListBuckets() statusCode = %v, wantStatusCode = %v", resp.StatusCode, tt.statusCode) + } + } +} diff --git a/s3api/controllers/backend_moq_test.go b/s3api/controllers/backend_moq_test.go index f2ff431..0aaaa38 100644 --- a/s3api/controllers/backend_moq_test.go +++ b/s3api/controllers/backend_moq_test.go @@ -70,9 +70,12 @@ var _ backend.Backend = &BackendMock{} // HeadObjectFunc: func(contextMoqParam context.Context, headObjectInput *s3.HeadObjectInput) (*s3.HeadObjectOutput, error) { // panic("mock out the HeadObject method") // }, -// ListBucketsFunc: func(contextMoqParam context.Context, owner string, isRoot bool) (s3response.ListAllMyBucketsResult, error) { +// ListBucketsFunc: func(contextMoqParam context.Context, owner string, isAdmin bool) (s3response.ListAllMyBucketsResult, error) { // panic("mock out the ListBuckets method") // }, +// ListBucketsAndOwnersFunc: func(contextMoqParam context.Context) ([]s3response.Bucket, error) { +// panic("mock out the ListBucketsAndOwners method") +// }, // ListMultipartUploadsFunc: func(contextMoqParam context.Context, listMultipartUploadsInput *s3.ListMultipartUploadsInput) (s3response.ListMultipartUploadsResult, error) { // panic("mock out the ListMultipartUploads method") // }, @@ -174,7 +177,10 @@ type BackendMock struct { HeadObjectFunc func(contextMoqParam context.Context, headObjectInput *s3.HeadObjectInput) (*s3.HeadObjectOutput, error) // ListBucketsFunc mocks the ListBuckets method. - ListBucketsFunc func(contextMoqParam context.Context, owner string, isRoot bool) (s3response.ListAllMyBucketsResult, error) + ListBucketsFunc func(contextMoqParam context.Context, owner string, isAdmin bool) (s3response.ListAllMyBucketsResult, error) + + // ListBucketsAndOwnersFunc mocks the ListBucketsAndOwners method. + ListBucketsAndOwnersFunc func(contextMoqParam context.Context) ([]s3response.Bucket, error) // ListMultipartUploadsFunc mocks the ListMultipartUploads method. ListMultipartUploadsFunc func(contextMoqParam context.Context, listMultipartUploadsInput *s3.ListMultipartUploadsInput) (s3response.ListMultipartUploadsResult, error) @@ -347,8 +353,13 @@ type BackendMock struct { ContextMoqParam context.Context // Owner is the owner argument value. Owner string - // IsRoot is the isRoot argument value. - IsRoot bool + // IsAdmin is the isAdmin argument value. + IsAdmin bool + } + // ListBucketsAndOwners holds details about calls to the ListBucketsAndOwners method. + ListBucketsAndOwners []struct { + // ContextMoqParam is the contextMoqParam argument value. + ContextMoqParam context.Context } // ListMultipartUploads holds details about calls to the ListMultipartUploads method. ListMultipartUploads []struct { @@ -473,6 +484,7 @@ type BackendMock struct { lockHeadBucket sync.RWMutex lockHeadObject sync.RWMutex lockListBuckets sync.RWMutex + lockListBucketsAndOwners sync.RWMutex lockListMultipartUploads sync.RWMutex lockListObjects sync.RWMutex lockListObjectsV2 sync.RWMutex @@ -1079,23 +1091,23 @@ func (mock *BackendMock) HeadObjectCalls() []struct { } // ListBuckets calls ListBucketsFunc. -func (mock *BackendMock) ListBuckets(contextMoqParam context.Context, owner string, isRoot bool) (s3response.ListAllMyBucketsResult, error) { +func (mock *BackendMock) ListBuckets(contextMoqParam context.Context, owner string, isAdmin bool) (s3response.ListAllMyBucketsResult, error) { if mock.ListBucketsFunc == nil { panic("BackendMock.ListBucketsFunc: method is nil but Backend.ListBuckets was just called") } callInfo := struct { ContextMoqParam context.Context Owner string - IsRoot bool + IsAdmin bool }{ ContextMoqParam: contextMoqParam, Owner: owner, - IsRoot: isRoot, + IsAdmin: isAdmin, } mock.lockListBuckets.Lock() mock.calls.ListBuckets = append(mock.calls.ListBuckets, callInfo) mock.lockListBuckets.Unlock() - return mock.ListBucketsFunc(contextMoqParam, owner, isRoot) + return mock.ListBucketsFunc(contextMoqParam, owner, isAdmin) } // ListBucketsCalls gets all the calls that were made to ListBuckets. @@ -1105,12 +1117,12 @@ func (mock *BackendMock) ListBuckets(contextMoqParam context.Context, owner stri func (mock *BackendMock) ListBucketsCalls() []struct { ContextMoqParam context.Context Owner string - IsRoot bool + IsAdmin bool } { var calls []struct { ContextMoqParam context.Context Owner string - IsRoot bool + IsAdmin bool } mock.lockListBuckets.RLock() calls = mock.calls.ListBuckets @@ -1118,6 +1130,38 @@ func (mock *BackendMock) ListBucketsCalls() []struct { return calls } +// ListBucketsAndOwners calls ListBucketsAndOwnersFunc. +func (mock *BackendMock) ListBucketsAndOwners(contextMoqParam context.Context) ([]s3response.Bucket, error) { + if mock.ListBucketsAndOwnersFunc == nil { + panic("BackendMock.ListBucketsAndOwnersFunc: method is nil but Backend.ListBucketsAndOwners was just called") + } + callInfo := struct { + ContextMoqParam context.Context + }{ + ContextMoqParam: contextMoqParam, + } + mock.lockListBucketsAndOwners.Lock() + mock.calls.ListBucketsAndOwners = append(mock.calls.ListBucketsAndOwners, callInfo) + mock.lockListBucketsAndOwners.Unlock() + return mock.ListBucketsAndOwnersFunc(contextMoqParam) +} + +// ListBucketsAndOwnersCalls gets all the calls that were made to ListBucketsAndOwners. +// Check the length with: +// +// len(mockedBackend.ListBucketsAndOwnersCalls()) +func (mock *BackendMock) ListBucketsAndOwnersCalls() []struct { + ContextMoqParam context.Context +} { + var calls []struct { + ContextMoqParam context.Context + } + mock.lockListBucketsAndOwners.RLock() + calls = mock.calls.ListBucketsAndOwners + mock.lockListBucketsAndOwners.RUnlock() + return calls +} + // ListMultipartUploads calls ListMultipartUploadsFunc. func (mock *BackendMock) ListMultipartUploads(contextMoqParam context.Context, listMultipartUploadsInput *s3.ListMultipartUploadsInput) (s3response.ListMultipartUploadsResult, error) { if mock.ListMultipartUploadsFunc == nil { diff --git a/s3api/router.go b/s3api/router.go index d32267f..f9eaa42 100644 --- a/s3api/router.go +++ b/s3api/router.go @@ -38,9 +38,12 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ // ListUsers admin api app.Patch("/list-users", adminController.ListUsers) - // ChangeBucketOwner + // ChangeBucketOwner admin api app.Patch("/change-bucket-owner", adminController.ChangeBucketOwner) + // ListBucketsAndOwners admin api + app.Patch("/list-buckets", adminController.ListBuckets) + // ListBuckets action app.Get("/", s3ApiController.ListBuckets) diff --git a/s3response/s3response.go b/s3response/s3response.go index 66743f2..edc8893 100644 --- a/s3response/s3response.go +++ b/s3response/s3response.go @@ -134,3 +134,8 @@ type SelectObjectContentResult struct { Cont *string End *string } + +type Bucket struct { + Name string `json:"name"` + Owner string `json:"owner"` +}