diff --git a/auth/iam.go b/auth/iam.go index 4a25d8c..72a8cca 100644 --- a/auth/iam.go +++ b/auth/iam.go @@ -31,6 +31,7 @@ type IAMService interface { CreateAccount(access string, account Account) error GetUserAccount(access string) (Account, error) DeleteUserAccount(access string) error + ListUserAccounts() ([]UserAcc, error) } var ErrNoSuchUser = errors.New("user not found") diff --git a/auth/iam_internal.go b/auth/iam_internal.go index 5a770d3..21fb05d 100644 --- a/auth/iam_internal.go +++ b/auth/iam_internal.go @@ -41,6 +41,12 @@ type Storer interface { StoreIAM(UpdateAcctFunc) error } +type UserAcc struct { + Access string `json:"access"` + Secret string `json:"secret"` + Role string `json:"role"` +} + // IAMConfig stores all internal IAM accounts type IAMConfig struct { AccessAccounts map[string]Account `json:"accessAccounts"` @@ -179,3 +185,34 @@ func (s *IAMServiceInternal) DeleteUserAccount(access string) error { return b, nil }) } + +// ListUserAccounts lists all the user accounts stored. +func (s *IAMServiceInternal) ListUserAccounts() (accs []UserAcc, err error) { + s.mu.RLock() + defer s.mu.RUnlock() + + data, err := s.storer.GetIAM() + if err != nil { + return []UserAcc{}, fmt.Errorf("get iam data: %w", err) + } + + serial := crc32.ChecksumIEEE(data) + if serial != s.serial { + s.mu.RUnlock() + err := s.updateCache() + s.mu.RLock() + if err != nil { + return []UserAcc{}, fmt.Errorf("refresh iam cache: %w", err) + } + } + + for access, usr := range s.accts.AccessAccounts { + accs = append(accs, UserAcc{ + Access: access, + Secret: usr.Secret, + Role: usr.Role, + }) + } + + return accs, nil +} diff --git a/cmd/versitygw/admin.go b/cmd/versitygw/admin.go index 75ef499..71b4fbf 100644 --- a/cmd/versitygw/admin.go +++ b/cmd/versitygw/admin.go @@ -17,6 +17,7 @@ package main import ( "crypto/sha256" "encoding/hex" + "encoding/json" "fmt" "io" "net/http" @@ -25,21 +26,20 @@ import ( "github.com/aws/aws-sdk-go-v2/aws" v4 "github.com/aws/aws-sdk-go-v2/aws/signer/v4" "github.com/urfave/cli/v2" + "github.com/versity/versitygw/auth" ) var ( - adminAccess string - adminSecret string + adminAccess string + adminSecret string + adminEndpoint string ) func adminCommand() *cli.Command { return &cli.Command{ - Name: "admin", - Usage: "admin CLI tool", - Description: `admin CLI tool for interacting with admin api. - Here is the available api list: - create-user - `, + Name: "admin", + Usage: "admin CLI tool", + Description: `Admin CLI tool for interacting with admin APIs.`, Subcommands: []*cli.Command{ { Name: "create-user", @@ -48,13 +48,13 @@ func adminCommand() *cli.Command { Flags: []cli.Flag{ &cli.StringFlag{ Name: "access", - Usage: "access value for the new user", + Usage: "access key id for the new user", Required: true, Aliases: []string{"a"}, }, &cli.StringFlag{ Name: "secret", - Usage: "secret value for the new user", + Usage: "secret access key for the new user", Required: true, Aliases: []string{"s"}, }, @@ -73,20 +73,26 @@ func adminCommand() *cli.Command { Flags: []cli.Flag{ &cli.StringFlag{ Name: "access", - Usage: "access value for the user to be deleted", + Usage: "access key id of the user to be deleted", Required: true, Aliases: []string{"a"}, }, }, }, + { + Name: "list-users", + Usage: "List all the gateway users", + Action: listUsers, + }, }, Flags: []cli.Flag{ // TODO: create a configuration file for this &cli.StringFlag{ Name: "access", - Usage: "admin access account", + Usage: "admin access key id", EnvVars: []string{"ADMIN_ACCESS_KEY_ID", "ADMIN_ACCESS_KEY"}, Aliases: []string{"a"}, + Required: true, Destination: &adminAccess, }, &cli.StringFlag{ @@ -94,8 +100,16 @@ func adminCommand() *cli.Command { Usage: "admin secret access key", EnvVars: []string{"ADMIN_SECRET_ACCESS_KEY", "ADMIN_SECRET_KEY"}, Aliases: []string{"s"}, + Required: true, Destination: &adminSecret, }, + &cli.StringFlag{ + Name: "endpoint-url", + Usage: "admin apis endpoint url", + Aliases: []string{"er"}, + Required: true, + Destination: &adminEndpoint, + }, }, } } @@ -109,7 +123,7 @@ func createUser(ctx *cli.Context) error { return fmt.Errorf("invalid input parameter for role") } - req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:7070/create-user?access=%v&secret=%v&role=%v", access, secret, role), nil) + req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%v/create-user?access=%v&secret=%v&role=%v", adminEndpoint, access, secret, role), nil) if err != nil { return fmt.Errorf("failed to send the request: %w", err) } @@ -137,6 +151,7 @@ func createUser(ctx *cli.Context) error { if err != nil { return err } + defer resp.Body.Close() fmt.Printf("%s\n", body) @@ -149,7 +164,7 @@ func deleteUser(ctx *cli.Context) error { return fmt.Errorf("invalid input parameter for the new user") } - req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("http://localhost:7070/delete-user?access=%v", access), nil) + req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%v/delete-user?access=%v", adminEndpoint, access), nil) if err != nil { return fmt.Errorf("failed to send the request: %w", err) } @@ -177,8 +192,55 @@ func deleteUser(ctx *cli.Context) error { if err != nil { return err } + defer resp.Body.Close() fmt.Printf("%s\n", body) return nil } + +func listUsers(ctx *cli.Context) error { + req, err := http.NewRequest(http.MethodPatch, fmt.Sprintf("%v/list-users", 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() + + var accs []auth.UserAcc + if err := json.Unmarshal(body, &accs); err != nil { + return err + } + + jsonData, err := json.MarshalIndent(accs, "", " ") + if err != nil { + return err + } + + fmt.Println(string(jsonData)) + + return nil +} diff --git a/integration/utils.go b/integration/utils.go index 8a3fd18..77b97b6 100644 --- a/integration/utils.go +++ b/integration/utils.go @@ -461,7 +461,7 @@ type user struct { func createUsers(s *S3Conf, users []user) error { for _, usr := range users { - out, err := execCommand("admin", "-a", s.awsID, "-s", s.awsSecret, "create-user", "-a", usr.access, "-s", usr.secret, "-r", usr.role) + out, err := execCommand("admin", "-a", s.awsID, "-s", s.awsSecret, "-er", s.endpoint, "create-user", "-a", usr.access, "-s", usr.secret, "-r", usr.role) if err != nil { return err } diff --git a/s3api/controllers/admin.go b/s3api/controllers/admin.go index a6b5740..2606b5e 100644 --- a/s3api/controllers/admin.go +++ b/s3api/controllers/admin.go @@ -60,3 +60,16 @@ func (c AdminController) DeleteUser(ctx *fiber.Ctx) error { return ctx.SendString("The user has been deleted successfully") } + +func (c AdminController) ListUsers(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") + } + accs, err := c.IAMService.ListUserAccounts() + if err != nil { + return err + } + + return ctx.JSON(accs) +} diff --git a/s3api/controllers/admin_test.go b/s3api/controllers/admin_test.go index 086ac01..6996368 100644 --- a/s3api/controllers/admin_test.go +++ b/s3api/controllers/admin_test.go @@ -15,6 +15,7 @@ package controllers import ( + "fmt" "net/http" "net/http/httptest" "testing" @@ -171,3 +172,99 @@ func TestAdminController_DeleteUser(t *testing.T) { } } } + +func TestAdminController_ListUsers(t *testing.T) { + type args struct { + req *http.Request + } + + adminController := AdminController{ + IAMService: &IAMServiceMock{ + ListUserAccountsFunc: func() ([]auth.UserAcc, error) { + return []auth.UserAcc{}, nil + }, + }, + } + + adminControllerErr := AdminController{ + IAMService: &IAMServiceMock{ + ListUserAccountsFunc: func() ([]auth.UserAcc, error) { + return []auth.UserAcc{}, fmt.Errorf("server error") + }, + }, + } + + appErr := fiber.New() + + appErr.Use(func(ctx *fiber.Ctx) error { + ctx.Locals("role", "admin") + return ctx.Next() + }) + + appErr.Patch("/list-users", adminControllerErr.ListUsers) + + appRoleErr := fiber.New() + + appRoleErr.Use(func(ctx *fiber.Ctx) error { + ctx.Locals("role", "user") + return ctx.Next() + }) + + appRoleErr.Patch("/list-users", adminController.ListUsers) + + appSucc := fiber.New() + + appSucc.Use(func(ctx *fiber.Ctx) error { + ctx.Locals("role", "admin") + return ctx.Next() + }) + + appSucc.Patch("/list-users", adminController.ListUsers) + + tests := []struct { + name string + app *fiber.App + args args + wantErr bool + statusCode int + }{ + { + name: "Admin-list-users-access-denied", + app: appRoleErr, + args: args{ + req: httptest.NewRequest(http.MethodPatch, "/list-users", nil), + }, + wantErr: false, + statusCode: 500, + }, + { + name: "Admin-list-users-iam-error", + app: appErr, + args: args{ + req: httptest.NewRequest(http.MethodPatch, "/list-users", nil), + }, + wantErr: false, + statusCode: 500, + }, + { + name: "Admin-list-users-success", + app: appSucc, + args: args{ + req: httptest.NewRequest(http.MethodPatch, "/list-users", 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.ListUsers() error = %v, wantErr %v", err, tt.wantErr) + } + + if resp.StatusCode != tt.statusCode { + t.Errorf("AdminController.ListUsers() statusCode = %v, wantStatusCode = %v", resp.StatusCode, tt.statusCode) + } + } +} diff --git a/s3api/controllers/iam_moq_test.go b/s3api/controllers/iam_moq_test.go index 8a858b1..eb2d531 100644 --- a/s3api/controllers/iam_moq_test.go +++ b/s3api/controllers/iam_moq_test.go @@ -27,6 +27,9 @@ var _ auth.IAMService = &IAMServiceMock{} // GetUserAccountFunc: func(access string) (auth.Account, error) { // panic("mock out the GetUserAccount method") // }, +// ListUserAccountsFunc: func() ([]auth.UserAcc, error) { +// panic("mock out the ListUserAccounts method") +// }, // } // // // use mockedIAMService in code that requires auth.IAMService @@ -43,6 +46,9 @@ type IAMServiceMock struct { // GetUserAccountFunc mocks the GetUserAccount method. GetUserAccountFunc func(access string) (auth.Account, error) + // ListUserAccountsFunc mocks the ListUserAccounts method. + ListUserAccountsFunc func() ([]auth.UserAcc, error) + // calls tracks calls to the methods. calls struct { // CreateAccount holds details about calls to the CreateAccount method. @@ -62,10 +68,14 @@ type IAMServiceMock struct { // Access is the access argument value. Access string } + // ListUserAccounts holds details about calls to the ListUserAccounts method. + ListUserAccounts []struct { + } } lockCreateAccount sync.RWMutex lockDeleteUserAccount sync.RWMutex lockGetUserAccount sync.RWMutex + lockListUserAccounts sync.RWMutex } // CreateAccount calls CreateAccountFunc. @@ -167,3 +177,30 @@ func (mock *IAMServiceMock) GetUserAccountCalls() []struct { mock.lockGetUserAccount.RUnlock() return calls } + +// ListUserAccounts calls ListUserAccountsFunc. +func (mock *IAMServiceMock) ListUserAccounts() ([]auth.UserAcc, error) { + if mock.ListUserAccountsFunc == nil { + panic("IAMServiceMock.ListUserAccountsFunc: method is nil but IAMService.ListUserAccounts was just called") + } + callInfo := struct { + }{} + mock.lockListUserAccounts.Lock() + mock.calls.ListUserAccounts = append(mock.calls.ListUserAccounts, callInfo) + mock.lockListUserAccounts.Unlock() + return mock.ListUserAccountsFunc() +} + +// ListUserAccountsCalls gets all the calls that were made to ListUserAccounts. +// Check the length with: +// +// len(mockedIAMService.ListUserAccountsCalls()) +func (mock *IAMServiceMock) ListUserAccountsCalls() []struct { +} { + var calls []struct { + } + mock.lockListUserAccounts.RLock() + calls = mock.calls.ListUserAccounts + mock.lockListUserAccounts.RUnlock() + return calls +} diff --git a/s3api/router.go b/s3api/router.go index 69740ce..c0e52a0 100644 --- a/s3api/router.go +++ b/s3api/router.go @@ -29,10 +29,15 @@ func (sa *S3ApiRouter) Init(app *fiber.App, be backend.Backend, iam auth.IAMServ s3ApiController := controllers.New(be, iam, logger, evs) adminController := controllers.AdminController{IAMService: iam} + //CreateUser admin api app.Patch("/create-user", adminController.CreateUser) - // Admin Delete api + // DeleteUsers admin api app.Patch("/delete-user", adminController.DeleteUser) + + // ListUsers admin api + app.Patch("/list-users", adminController.ListUsers) + // ListBuckets action app.Get("/", s3ApiController.ListBuckets)