From d7a6989657f212f9c7bea35fba44dfd35c20991c Mon Sep 17 00:00:00 2001 From: dhernando Date: Thu, 2 Apr 2026 18:41:36 +0200 Subject: [PATCH] feat: add user and invite commands --- internal/cmd/iam/iam.go | 4 + internal/cmd/iam/iam_test.go | 487 +++++++++++++++++++++++++++ internal/cmd/iam/invite.go | 25 ++ internal/cmd/iam/invite_delete.go | 65 ++++ internal/cmd/iam/invite_describe.go | 66 ++++ internal/cmd/iam/invite_list.go | 64 ++++ internal/cmd/iam/resolve.go | 78 +++++ internal/cmd/iam/user.go | 27 ++ internal/cmd/iam/user_assign_role.go | 86 +++++ internal/cmd/iam/user_describe.go | 170 ++++++++++ internal/cmd/iam/user_invite.go | 77 +++++ internal/cmd/iam/user_list.go | 62 ++++ internal/cmd/iam/user_remove_role.go | 86 +++++ internal/cmd/output/iam.go | 18 + internal/qcloudapi/client.go | 16 + internal/testutil/fake_account.go | 43 +++ internal/testutil/fake_iam.go | 49 +++ internal/testutil/server.go | 10 + 18 files changed, 1433 insertions(+) create mode 100644 internal/cmd/iam/iam_test.go create mode 100644 internal/cmd/iam/invite.go create mode 100644 internal/cmd/iam/invite_delete.go create mode 100644 internal/cmd/iam/invite_describe.go create mode 100644 internal/cmd/iam/invite_list.go create mode 100644 internal/cmd/iam/resolve.go create mode 100644 internal/cmd/iam/user.go create mode 100644 internal/cmd/iam/user_assign_role.go create mode 100644 internal/cmd/iam/user_describe.go create mode 100644 internal/cmd/iam/user_invite.go create mode 100644 internal/cmd/iam/user_list.go create mode 100644 internal/cmd/iam/user_remove_role.go create mode 100644 internal/cmd/output/iam.go create mode 100644 internal/testutil/fake_account.go create mode 100644 internal/testutil/fake_iam.go diff --git a/internal/cmd/iam/iam.go b/internal/cmd/iam/iam.go index 4113e3e..88171bb 100644 --- a/internal/cmd/iam/iam.go +++ b/internal/cmd/iam/iam.go @@ -14,5 +14,9 @@ func NewCommand(s *state.State) *cobra.Command { Long: `Manage IAM resources for the Qdrant Cloud account.`, Args: cobra.NoArgs, } + cmd.AddCommand( + newUserCommand(s), + newInviteCommand(s), + ) return cmd } diff --git a/internal/cmd/iam/iam_test.go b/internal/cmd/iam/iam_test.go new file mode 100644 index 0000000..2dcea05 --- /dev/null +++ b/internal/cmd/iam/iam_test.go @@ -0,0 +1,487 @@ +package iam_test + +import ( + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +const ( + testUserID = "7b2ea926-724b-4de2-b73a-8675c42a6ebe" + testRoleID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee" +) + +// --- user list --- + +func TestUserList_TableOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{ + { + Id: "user-1", + Email: "alice@example.com", + Status: iamv1.UserStatus_USER_STATUS_ACTIVE, + CreatedAt: timestamppb.New(time.Now().Add(-48 * time.Hour)), + }, + { + Id: "user-2", + Email: "bob@example.com", + Status: iamv1.UserStatus_USER_STATUS_BLOCKED, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "list") + require.NoError(t, err) + + assert.Contains(t, stdout, "user-1") + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, "ACTIVE") + assert.Contains(t, stdout, "user-2") + assert.Contains(t, stdout, "bob@example.com") + assert.Contains(t, stdout, "BLOCKED") + + req, ok := env.IAMServer.ListUsersCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetAccountId()) +} + +func TestUserList_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: "user-1", Email: "alice@example.com"}}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "list", "--json") + require.NoError(t, err) + + assert.Contains(t, stdout, `"id"`) + assert.Contains(t, stdout, "user-1") +} + +func TestUserList_Error(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(nil, fmt.Errorf("permission denied")) + + _, _, err := testutil.Exec(t, env, "iam", "user", "list") + require.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") +} + +// --- user describe --- + +func TestUserDescribe_ByID(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + cat := "Cluster" + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{ + {Id: userID, Email: "alice@example.com", Status: iamv1.UserStatus_USER_STATUS_ACTIVE}, + }, + }, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{ + { + Id: "role-id-1", + Name: "admin", + Permissions: []*iamv1.Permission{ + {Value: "read:clusters", Category: &cat}, + {Value: "write:clusters", Category: &cat}, + }, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", userID) + require.NoError(t, err) + + assert.Contains(t, stdout, userID) + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, "ACTIVE") + assert.Contains(t, stdout, "role-id-1") + assert.Contains(t, stdout, "admin") + assert.Contains(t, stdout, "read:clusters") + assert.Contains(t, stdout, "write:clusters") + assert.Contains(t, stdout, "Cluster") + + req, ok := env.IAMServer.ListUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, userID, req.GetUserId()) +} + +func TestUserDescribe_PermissionsDeduplicatedWithRoles(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + cat := "Cluster" + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{ + { + Id: "role-id-1", + Name: "admin", + Permissions: []*iamv1.Permission{ + {Value: "read:clusters", Category: &cat}, + {Value: "write:clusters", Category: &cat}, + }, + }, + { + Id: "role-id-2", + Name: "viewer", + Permissions: []*iamv1.Permission{ + {Value: "read:clusters", Category: &cat}, + }, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", userID) + require.NoError(t, err) + + // read:clusters appears in both roles — should be listed once with both role names + assert.Contains(t, stdout, "admin, viewer") + // write:clusters only in admin + assert.Contains(t, stdout, "write:clusters") +} + +func TestUserDescribe_NoPermissions(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{ + {Id: userID, Email: "alice@example.com"}, + }, + }, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{ + {Id: "role-id-1", Name: "viewer"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", userID) + require.NoError(t, err) + + assert.NotContains(t, stdout, "Effective Permissions") +} + +func TestUserDescribe_ByEmail(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{ + {Id: "user-id-abc", Email: "alice@example.com"}, + }, + }, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "describe", "alice@example.com") + require.NoError(t, err) + + assert.Contains(t, stdout, "alice@example.com") + req, ok := env.IAMServer.ListUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, "user-id-abc", req.GetUserId()) +} + +func TestUserDescribe_NotFound(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{Items: nil}, nil) + + _, _, err := testutil.Exec(t, env, "iam", "user", "describe", "nobody@example.com") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +// --- user assign-role --- + +func TestUserAssignRole_ByRoleID(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + roleID := testRoleID + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{{Id: roleID, Name: "admin"}}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", + "alice@example.com", roleID) + require.NoError(t, err) + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, roleID) + assert.Contains(t, stdout, "admin") + + req, ok := env.IAMServer.AssignUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, userID, req.GetUserId()) + assert.Equal(t, []string{roleID}, req.GetRoleIdsToAdd()) +} + +func TestUserAssignRole_ByRoleName(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + roleID := testRoleID + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{{Id: roleID, Name: "admin"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{ + Roles: []*iamv1.Role{{Id: roleID, Name: "admin"}}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", + "alice@example.com", "admin") + require.NoError(t, err) + assert.Contains(t, stdout, "admin") + + req, ok := env.IAMServer.AssignUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, []string{roleID}, req.GetRoleIdsToAdd()) +} + +func TestUserAssignRole_MissingRole(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "user", "assign-role", "alice@example.com") + require.Error(t, err) +} + +// --- user remove-role --- + +func TestUserRemoveRole_ByRoleID(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + roleID := testRoleID + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", + "alice@example.com", roleID) + require.NoError(t, err) + assert.Contains(t, stdout, "alice@example.com") + + req, ok := env.IAMServer.AssignUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, userID, req.GetUserId()) + assert.Equal(t, []string{roleID}, req.GetRoleIdsToDelete()) +} + +func TestUserRemoveRole_ByRoleName(t *testing.T) { + env := testutil.NewTestEnv(t) + + userID := testUserID + roleID := testRoleID + + env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{ + Items: []*iamv1.User{{Id: userID, Email: "alice@example.com"}}, + }, nil) + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{{Id: roleID, Name: "viewer"}}, + }, nil) + env.IAMServer.AssignUserRolesCalls.Returns(&iamv1.AssignUserRolesResponse{}, nil) + env.IAMServer.ListUserRolesCalls.Returns(&iamv1.ListUserRolesResponse{}, nil) + + _, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", + "alice@example.com", "viewer") + require.NoError(t, err) + + req, ok := env.IAMServer.AssignUserRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, []string{roleID}, req.GetRoleIdsToDelete()) +} + +func TestUserRemoveRole_MissingRole(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "user", "remove-role", "alice@example.com") + require.Error(t, err) +} + +// --- user invite --- + +func TestUserInvite(t *testing.T) { + env := testutil.NewTestEnv(t) + + inviteID := "invite-id-123" + env.AccountServer.CreateAccountInviteCalls.Returns(&accountv1.CreateAccountInviteResponse{ + AccountInvite: &accountv1.AccountInvite{Id: inviteID, UserEmail: "bob@example.com"}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "user", "invite", + "--email", "bob@example.com") + require.NoError(t, err) + assert.Contains(t, stdout, inviteID) + assert.Contains(t, stdout, "bob@example.com") + + req, ok := env.AccountServer.CreateAccountInviteCalls.Last() + require.True(t, ok) + assert.Equal(t, "bob@example.com", req.GetAccountInvite().GetUserEmail()) + assert.Equal(t, "test-account-id", req.GetAccountInvite().GetAccountId()) +} + +func TestUserInvite_WithRole(t *testing.T) { + env := testutil.NewTestEnv(t) + + roleID := testRoleID + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{{Id: roleID, Name: "viewer"}}, + }, nil) + env.AccountServer.CreateAccountInviteCalls.Returns(&accountv1.CreateAccountInviteResponse{ + AccountInvite: &accountv1.AccountInvite{Id: "invite-id", UserEmail: "bob@example.com"}, + }, nil) + + _, _, err := testutil.Exec(t, env, "iam", "user", "invite", + "--email", "bob@example.com", "--role", "viewer") + require.NoError(t, err) + + req, ok := env.AccountServer.CreateAccountInviteCalls.Last() + require.True(t, ok) + assert.Equal(t, []string{roleID}, req.GetAccountInvite().GetUserRoleIds()) +} + +func TestUserInvite_MissingEmail(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "user", "invite") + require.Error(t, err) +} + +// --- invite list --- + +func TestInviteList_TableOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AccountServer.ListAccountInvitesCalls.Returns(&accountv1.ListAccountInvitesResponse{ + Items: []*accountv1.AccountInvite{ + { + Id: "invite-1", + UserEmail: "alice@example.com", + Status: accountv1.AccountInviteStatus_ACCOUNT_INVITE_STATUS_PENDING, + CreatedAt: timestamppb.New(time.Now().Add(-1 * time.Hour)), + }, + { + Id: "invite-2", + UserEmail: "bob@example.com", + Status: accountv1.AccountInviteStatus_ACCOUNT_INVITE_STATUS_ACCEPTED, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "invite", "list") + require.NoError(t, err) + + assert.Contains(t, stdout, "invite-1") + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, "PENDING") + assert.Contains(t, stdout, "invite-2") + assert.Contains(t, stdout, "bob@example.com") + assert.Contains(t, stdout, "ACCEPTED") + + req, ok := env.AccountServer.ListAccountInvitesCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetAccountId()) +} + +func TestInviteList_Error(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AccountServer.ListAccountInvitesCalls.Returns(nil, fmt.Errorf("permission denied")) + + _, _, err := testutil.Exec(t, env, "iam", "invite", "list") + require.Error(t, err) + assert.Contains(t, err.Error(), "permission denied") +} + +// --- invite describe --- + +func TestInviteDescribe(t *testing.T) { + env := testutil.NewTestEnv(t) + + inviteID := testUserID + env.AccountServer.GetAccountInviteCalls.Returns(&accountv1.GetAccountInviteResponse{ + AccountInvite: &accountv1.AccountInvite{ + Id: inviteID, + UserEmail: "alice@example.com", + Status: accountv1.AccountInviteStatus_ACCOUNT_INVITE_STATUS_PENDING, + UserRoleIds: []string{"role-1"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "invite", "describe", inviteID) + require.NoError(t, err) + + assert.Contains(t, stdout, inviteID) + assert.Contains(t, stdout, "alice@example.com") + assert.Contains(t, stdout, "PENDING") + assert.Contains(t, stdout, "role-1") +} + +func TestInviteDescribe_Error(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AccountServer.GetAccountInviteCalls.Returns(nil, fmt.Errorf("not found")) + + _, _, err := testutil.Exec(t, env, "iam", "invite", "describe", testUserID) + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + +// --- invite delete --- + +func TestInviteDelete(t *testing.T) { + env := testutil.NewTestEnv(t) + + inviteID := testUserID + env.AccountServer.DeleteAccountInviteCalls.Returns(&accountv1.DeleteAccountInviteResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "invite", "delete", inviteID, "--force") + require.NoError(t, err) + assert.Contains(t, stdout, "deleted") + + req, ok := env.AccountServer.DeleteAccountInviteCalls.Last() + require.True(t, ok) + assert.Equal(t, inviteID, req.GetInviteId()) +} + +func TestInviteDelete_Error(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.AccountServer.DeleteAccountInviteCalls.Returns(nil, fmt.Errorf("not found")) + + _, _, err := testutil.Exec(t, env, "iam", "invite", "delete", + testUserID, "--force") + require.Error(t, err) + assert.Contains(t, err.Error(), "not found") +} + diff --git a/internal/cmd/iam/invite.go b/internal/cmd/iam/invite.go new file mode 100644 index 0000000..f9f086e --- /dev/null +++ b/internal/cmd/iam/invite.go @@ -0,0 +1,25 @@ +package iam + +import ( + "github.com/spf13/cobra" + + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newInviteCommand(s *state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "invite", + Short: "Manage account invites", + Long: `Manage account invites in Qdrant Cloud. + +Provides commands to list, view, and delete account invites. +To send a new invite, use the 'iam user invite' command.`, + Args: cobra.NoArgs, + } + cmd.AddCommand( + newInviteListCommand(s), + newInviteDescribeCommand(s), + newInviteDeleteCommand(s), + ) + return cmd +} diff --git a/internal/cmd/iam/invite_delete.go b/internal/cmd/iam/invite_delete.go new file mode 100644 index 0000000..b29a782 --- /dev/null +++ b/internal/cmd/iam/invite_delete.go @@ -0,0 +1,65 @@ +package iam + +import ( + "fmt" + + "github.com/spf13/cobra" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newInviteDeleteCommand(s *state.State) *cobra.Command { + return base.Cmd{ + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete an account invite", + Args: util.ExactArgs(1, "an invite ID"), + } + cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + return cmd + }, + Long: `Delete an account invite. + +Cancels a pending account invite. The invited user will no longer be able to +accept or reject the invite. Requires the delete:invites permission.`, + Example: `# Delete an invite +qcloud iam invite delete 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Delete without confirmation +qcloud iam invite delete 7b2ea926-724b-4de2-b73a-8675c42a6ebe --force`, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + force, _ := cmd.Flags().GetBool("force") + if !util.ConfirmAction(force, cmd.ErrOrStderr(), + fmt.Sprintf("Delete invite %s?", args[0])) { + fmt.Fprintln(cmd.OutOrStdout(), "Aborted.") + return nil + } + + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return err + } + accountID, err := s.AccountID() + if err != nil { + return err + } + + _, err = client.Account().DeleteAccountInvite(ctx, &accountv1.DeleteAccountInviteRequest{ + AccountId: accountID, + InviteId: args[0], + }) + if err != nil { + return fmt.Errorf("failed to delete invite: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Invite %s deleted.\n", args[0]) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/invite_describe.go b/internal/cmd/iam/invite_describe.go new file mode 100644 index 0000000..05cd82c --- /dev/null +++ b/internal/cmd/iam/invite_describe.go @@ -0,0 +1,66 @@ +package iam + +import ( + "fmt" + "io" + "strings" + + "github.com/spf13/cobra" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newInviteDescribeCommand(s *state.State) *cobra.Command { + return base.DescribeCmd[*accountv1.AccountInvite]{ + Use: "describe ", + Short: "Describe an account invite", + Long: `Describe an account invite. + +Displays the full details of a specific account invite, including the invited +email address, assigned roles, and current status. Requires the read:invites +permission.`, + Example: `# Describe an invite by ID +qcloud iam invite describe 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Output as JSON +qcloud iam invite describe 7b2ea926-724b-4de2-b73a-8675c42a6ebe --json`, + Args: util.ExactArgs(1, "an invite ID"), + Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*accountv1.AccountInvite, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + accountID, err := s.AccountID() + if err != nil { + return nil, err + } + resp, err := client.Account().GetAccountInvite(ctx, &accountv1.GetAccountInviteRequest{ + AccountId: accountID, + InviteId: args[0], + }) + if err != nil { + return nil, fmt.Errorf("failed to get invite: %w", err) + } + return resp.GetAccountInvite(), nil + }, + PrintText: func(_ *cobra.Command, w io.Writer, inv *accountv1.AccountInvite) error { + fmt.Fprintf(w, "ID: %s\n", inv.GetId()) + fmt.Fprintf(w, "Email: %s\n", inv.GetUserEmail()) + fmt.Fprintf(w, "Status: %s\n", output.AccountInviteStatus(inv.GetStatus())) + if len(inv.GetUserRoleIds()) > 0 { + fmt.Fprintf(w, "Roles: %s\n", strings.Join(inv.GetUserRoleIds(), ", ")) + } + if inv.GetCreatedAt() != nil { + t := inv.GetCreatedAt().AsTime() + fmt.Fprintf(w, "Created: %s (%s)\n", output.HumanTime(t), output.FullDateTime(t)) + } + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/invite_list.go b/internal/cmd/iam/invite_list.go new file mode 100644 index 0000000..2addc46 --- /dev/null +++ b/internal/cmd/iam/invite_list.go @@ -0,0 +1,64 @@ +package iam + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newInviteListCommand(s *state.State) *cobra.Command { + return base.ListCmd[*accountv1.ListAccountInvitesResponse]{ + Use: "list", + Short: "List account invites", + Long: `List account invites. + +Lists all invites for the current account. By default, invites of all statuses +are returned. Requires the read:invites permission.`, + Example: `# List all invites +qcloud iam invite list + +# Output as JSON +qcloud iam invite list --json`, + Fetch: func(s *state.State, cmd *cobra.Command) (*accountv1.ListAccountInvitesResponse, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + accountID, err := s.AccountID() + if err != nil { + return nil, err + } + resp, err := client.Account().ListAccountInvites(ctx, &accountv1.ListAccountInvitesRequest{ + AccountId: accountID, + }) + if err != nil { + return nil, fmt.Errorf("failed to list invites: %w", err) + } + return resp, nil + }, + PrintText: func(_ *cobra.Command, w io.Writer, resp *accountv1.ListAccountInvitesResponse) error { + t := output.NewTable[*accountv1.AccountInvite](w) + t.AddField("ID", func(v *accountv1.AccountInvite) string { return v.GetId() }) + t.AddField("EMAIL", func(v *accountv1.AccountInvite) string { return v.GetUserEmail() }) + t.AddField("STATUS", func(v *accountv1.AccountInvite) string { + return output.AccountInviteStatus(v.GetStatus()) + }) + t.AddField("CREATED", func(v *accountv1.AccountInvite) string { + if v.GetCreatedAt() != nil { + return output.HumanTime(v.GetCreatedAt().AsTime()) + } + return "" + }) + t.Write(resp.GetItems()) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/resolve.go b/internal/cmd/iam/resolve.go new file mode 100644 index 0000000..8538d91 --- /dev/null +++ b/internal/cmd/iam/resolve.go @@ -0,0 +1,78 @@ +package iam + +import ( + "context" + "fmt" + + "github.com/spf13/cobra" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/qcloudapi" +) + +// resolveUser looks up a user by UUID or email from the account's user list. +func resolveUser(cmd *cobra.Command, client *qcloudapi.Client, accountID, idOrEmail string) (*iamv1.User, error) { + ctx := cmd.Context() + resp, err := client.IAM().ListUsers(ctx, &iamv1.ListUsersRequest{AccountId: accountID}) + if err != nil { + return nil, fmt.Errorf("failed to list users: %w", err) + } + for _, u := range resp.GetItems() { + if util.IsUUID(idOrEmail) { + if u.GetId() == idOrEmail { + return u, nil + } + } else { + if u.GetEmail() == idOrEmail { + return u, nil + } + } + } + return nil, fmt.Errorf("user %s not found", idOrEmail) +} + +// resolveRoleIDs converts a slice of role names or UUIDs to UUIDs. +// Values that already look like UUIDs are passed through unchanged. +// Non-UUID values are resolved by name via ListRoles. +func resolveRoleIDs(ctx context.Context, client *qcloudapi.Client, accountID string, namesOrIDs []string) ([]string, error) { + if len(namesOrIDs) == 0 { + return nil, nil + } + + // Check whether any name resolution is needed. + var needsLookup bool + for _, v := range namesOrIDs { + if !util.IsUUID(v) { + needsLookup = true + break + } + } + + var rolesByName map[string]string + if needsLookup { + resp, err := client.IAM().ListRoles(ctx, &iamv1.ListRolesRequest{AccountId: accountID}) + if err != nil { + return nil, fmt.Errorf("failed to list roles: %w", err) + } + rolesByName = make(map[string]string, len(resp.GetItems())) + for _, r := range resp.GetItems() { + rolesByName[r.GetName()] = r.GetId() + } + } + + ids := make([]string, 0, len(namesOrIDs)) + for _, v := range namesOrIDs { + if util.IsUUID(v) { + ids = append(ids, v) + } else { + id, ok := rolesByName[v] + if !ok { + return nil, fmt.Errorf("role %q not found", v) + } + ids = append(ids, id) + } + } + return ids, nil +} diff --git a/internal/cmd/iam/user.go b/internal/cmd/iam/user.go new file mode 100644 index 0000000..570f4db --- /dev/null +++ b/internal/cmd/iam/user.go @@ -0,0 +1,27 @@ +package iam + +import ( + "github.com/spf13/cobra" + + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newUserCommand(s *state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "user", + Short: "Manage users in Qdrant Cloud", + Long: `Manage users in the Qdrant Cloud account. + +Provides commands to list users, view user details and assigned roles, and +manage role assignments.`, + Args: cobra.NoArgs, + } + cmd.AddCommand( + newUserListCommand(s), + newUserDescribeCommand(s), + newUserAssignRoleCommand(s), + newUserRemoveRoleCommand(s), + newUserInviteCommand(s), + ) + return cmd +} diff --git a/internal/cmd/iam/user_assign_role.go b/internal/cmd/iam/user_assign_role.go new file mode 100644 index 0000000..c6f2c80 --- /dev/null +++ b/internal/cmd/iam/user_assign_role.go @@ -0,0 +1,86 @@ +package iam + +import ( + "fmt" + + "github.com/spf13/cobra" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newUserAssignRoleCommand(s *state.State) *cobra.Command { + return base.Cmd{ + BaseCobraCommand: func() *cobra.Command { + return &cobra.Command{ + Use: "assign-role [...]", + Short: "Assign one or more roles to a user", + Args: cobra.MinimumNArgs(2), + } + }, + Long: `Assign one or more roles to a user in the account. + +Accepts either a user ID (UUID) or an email address to identify the user. +Each role argument accepts either a role UUID or a role name, which is +resolved to an ID via the IAM service. Prints the user's resulting roles +after the assignment.`, + Example: `# Assign a role by name +qcloud iam user assign-role user@example.com admin + +# Assign a role by ID +qcloud iam user assign-role user@example.com 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Assign multiple roles at once +qcloud iam user assign-role user@example.com admin viewer`, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return err + } + accountID, err := s.AccountID() + if err != nil { + return err + } + + user, err := resolveUser(cmd, client, accountID, args[0]) + if err != nil { + return err + } + + roleIDs, err := resolveRoleIDs(ctx, client, accountID, args[1:]) + if err != nil { + return err + } + + _, err = client.IAM().AssignUserRoles(ctx, &iamv1.AssignUserRolesRequest{ + AccountId: accountID, + UserId: user.GetId(), + RoleIdsToAdd: roleIDs, + }) + if err != nil { + return fmt.Errorf("failed to assign roles: %w", err) + } + + rolesResp, err := client.IAM().ListUserRoles(ctx, &iamv1.ListUserRolesRequest{ + AccountId: accountID, + UserId: user.GetId(), + }) + if err != nil { + return fmt.Errorf("failed to list user roles: %w", err) + } + + if s.Config.JSONOutput() { + return output.PrintJSON(cmd.OutOrStdout(), rolesResp) + } + + w := cmd.OutOrStdout() + fmt.Fprintf(w, "Roles for %s:\n", user.GetEmail()) + printRoles(w, rolesResp.GetRoles()) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/user_describe.go b/internal/cmd/iam/user_describe.go new file mode 100644 index 0000000..9772031 --- /dev/null +++ b/internal/cmd/iam/user_describe.go @@ -0,0 +1,170 @@ +package iam + +import ( + "fmt" + "io" + "sort" + "strings" + + "github.com/spf13/cobra" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newUserDescribeCommand(s *state.State) *cobra.Command { + return base.Cmd{ + BaseCobraCommand: func() *cobra.Command { + return &cobra.Command{ + Use: "describe ", + Short: "Describe a user and their assigned roles", + Args: util.ExactArgs(1, "a user ID or email"), + } + }, + Long: `Describe a user and their assigned roles. + +Accepts either a user ID (UUID) or an email address. Displays the user's +details and the roles currently assigned to them in the account.`, + Example: `# Describe a user by ID +qcloud iam user describe 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Describe a user by email +qcloud iam user describe user@example.com + +# Output as JSON +qcloud iam user describe user@example.com --json`, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return err + } + accountID, err := s.AccountID() + if err != nil { + return err + } + + // Resolve the user. + var user *iamv1.User + resp, err := client.IAM().ListUsers(ctx, &iamv1.ListUsersRequest{AccountId: accountID}) + if err != nil { + return fmt.Errorf("failed to list users: %w", err) + } + if util.IsUUID(args[0]) { + for _, u := range resp.GetItems() { + if u.GetId() == args[0] { + user = u + break + } + } + } else { + for _, u := range resp.GetItems() { + if u.GetEmail() == args[0] { + user = u + break + } + } + } + if user == nil { + return fmt.Errorf("user %s not found", args[0]) + } + + // Fetch the user's roles. + rolesResp, err := client.IAM().ListUserRoles(ctx, &iamv1.ListUserRolesRequest{ + AccountId: accountID, + UserId: user.GetId(), + }) + if err != nil { + return fmt.Errorf("failed to list user roles: %w", err) + } + roles := rolesResp.GetRoles() + + permissions := effectivePermissions(roles) + + if s.Config.JSONOutput() { + return output.PrintJSON(cmd.OutOrStdout(), &iamv1.ListUserRolesResponse{ + Roles: append([]*iamv1.Role{}, roles...), + }) + } + + w := cmd.OutOrStdout() + return printUserWithRoles(w, user, roles, permissions) + }, + }.CobraCommand(s) +} + +func printUserWithRoles(w io.Writer, user *iamv1.User, roles []*iamv1.Role, permissions []rolePermission) error { + fmt.Fprintf(w, "ID: %s\n", user.GetId()) + fmt.Fprintf(w, "Email: %s\n", user.GetEmail()) + fmt.Fprintf(w, "Status: %s\n", output.UserStatus(user.GetStatus())) + if user.GetCreatedAt() != nil { + t := user.GetCreatedAt().AsTime() + fmt.Fprintf(w, "Created: %s (%s)\n", output.HumanTime(t), output.FullDateTime(t)) + } + if len(roles) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "Roles:") + printRoles(w, roles) + } + if len(permissions) > 0 { + fmt.Fprintln(w) + fmt.Fprintln(w, "Effective Permissions:") + printPermissions(w, permissions) + } + return nil +} + +func printRoles(w io.Writer, roles []*iamv1.Role) { + t := output.NewTable[*iamv1.Role](w) + t.AddField("ID", func(v *iamv1.Role) string { return v.GetId() }) + t.AddField("NAME", func(v *iamv1.Role) string { return v.GetName() }) + t.Write(roles) +} + +type rolePermission struct { + permission *iamv1.Permission + roleNames []string +} + +func printPermissions(w io.Writer, rps []rolePermission) { + t := output.NewTable[rolePermission](w) + t.AddField("PERMISSION", func(v rolePermission) string { return v.permission.GetValue() }) + t.AddField("CATEGORY", func(v rolePermission) string { return v.permission.GetCategory() }) + t.AddField("FROM ROLES", func(v rolePermission) string { return strings.Join(v.roleNames, ", ") }) + t.Write(rps) +} + +// effectivePermissions collects unique permissions across all roles, with the +// sorted list of role names that grant each permission. Results are sorted by +// permission value. +func effectivePermissions(roles []*iamv1.Role) []rolePermission { + type entry struct { + permission *iamv1.Permission + roleNames []string + } + seen := make(map[string]*entry) + order := []string{} + for _, role := range roles { + for _, p := range role.GetPermissions() { + v := p.GetValue() + if e, ok := seen[v]; ok { + e.roleNames = append(e.roleNames, role.GetName()) + } else { + seen[v] = &entry{permission: p, roleNames: []string{role.GetName()}} + order = append(order, v) + } + } + } + sort.Strings(order) + out := make([]rolePermission, 0, len(order)) + for _, v := range order { + e := seen[v] + sort.Strings(e.roleNames) + out = append(out, rolePermission{permission: e.permission, roleNames: e.roleNames}) + } + return out +} diff --git a/internal/cmd/iam/user_invite.go b/internal/cmd/iam/user_invite.go new file mode 100644 index 0000000..4883f82 --- /dev/null +++ b/internal/cmd/iam/user_invite.go @@ -0,0 +1,77 @@ +package iam + +import ( + "fmt" + + "github.com/spf13/cobra" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newUserInviteCommand(s *state.State) *cobra.Command { + return base.Cmd{ + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "invite", + Short: "Invite a user to the account", + Args: cobra.NoArgs, + } + cmd.Flags().String("email", "", "Email address of the user to invite (required)") + _ = cmd.MarkFlagRequired("email") + cmd.Flags().StringArray("role", nil, "Role ID or name to assign to the invited user (repeatable)") + return cmd + }, + Long: `Invite a user to the account. + +Sends an account invite to the specified email address. The invited user will +receive an invitation they can accept or reject. + +Use --role to pre-assign roles to the invited user upon acceptance. Each +--role flag accepts either a role UUID or a role name.`, + Example: `# Invite a user with no roles +qcloud iam user invite --email user@example.com + +# Invite a user and assign a role by name +qcloud iam user invite --email user@example.com --role admin + +# Invite a user and assign multiple roles +qcloud iam user invite --email user@example.com --role viewer --role admin`, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + email, _ := cmd.Flags().GetString("email") + roleNames, _ := cmd.Flags().GetStringArray("role") + + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return err + } + accountID, err := s.AccountID() + if err != nil { + return err + } + + roleIDs, err := resolveRoleIDs(ctx, client, accountID, roleNames) + if err != nil { + return fmt.Errorf("--role: %w", err) + } + + resp, err := client.Account().CreateAccountInvite(ctx, &accountv1.CreateAccountInviteRequest{ + AccountInvite: &accountv1.AccountInvite{ + AccountId: accountID, + UserEmail: email, + UserRoleIds: roleIDs, + }, + }) + if err != nil { + return fmt.Errorf("failed to create invite: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Invite %s sent to %s.\n", + resp.GetAccountInvite().GetId(), email) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/user_list.go b/internal/cmd/iam/user_list.go new file mode 100644 index 0000000..4f03b4b --- /dev/null +++ b/internal/cmd/iam/user_list.go @@ -0,0 +1,62 @@ +package iam + +import ( + "fmt" + "io" + + "github.com/spf13/cobra" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newUserListCommand(s *state.State) *cobra.Command { + return base.ListCmd[*iamv1.ListUsersResponse]{ + Use: "list", + Short: "List users in the account", + Long: `List users in the account. + +Lists all users who are members of the current account. Requires the read:users +permission.`, + Example: `# List all users in the account +qcloud iam user list + +# Output as JSON +qcloud iam user list --json`, + Fetch: func(s *state.State, cmd *cobra.Command) (*iamv1.ListUsersResponse, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + accountID, err := s.AccountID() + if err != nil { + return nil, err + } + resp, err := client.IAM().ListUsers(ctx, &iamv1.ListUsersRequest{ + AccountId: accountID, + }) + if err != nil { + return nil, fmt.Errorf("failed to list users: %w", err) + } + return resp, nil + }, + PrintText: func(_ *cobra.Command, w io.Writer, resp *iamv1.ListUsersResponse) error { + t := output.NewTable[*iamv1.User](w) + t.AddField("ID", func(v *iamv1.User) string { return v.GetId() }) + t.AddField("EMAIL", func(v *iamv1.User) string { return v.GetEmail() }) + t.AddField("STATUS", func(v *iamv1.User) string { return output.UserStatus(v.GetStatus()) }) + t.AddField("CREATED", func(v *iamv1.User) string { + if v.GetCreatedAt() != nil { + return output.HumanTime(v.GetCreatedAt().AsTime()) + } + return "" + }) + t.Write(resp.GetItems()) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/user_remove_role.go b/internal/cmd/iam/user_remove_role.go new file mode 100644 index 0000000..a0e8f93 --- /dev/null +++ b/internal/cmd/iam/user_remove_role.go @@ -0,0 +1,86 @@ +package iam + +import ( + "fmt" + + "github.com/spf13/cobra" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/cmd/base" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newUserRemoveRoleCommand(s *state.State) *cobra.Command { + return base.Cmd{ + BaseCobraCommand: func() *cobra.Command { + return &cobra.Command{ + Use: "remove-role [...]", + Short: "Remove one or more roles from a user", + Args: cobra.MinimumNArgs(2), + } + }, + Long: `Remove one or more roles from a user in the account. + +Accepts either a user ID (UUID) or an email address to identify the user. +Each role argument accepts either a role UUID or a role name, which is +resolved to an ID via the IAM service. Prints the user's resulting roles +after the removal.`, + Example: `# Remove a role by name +qcloud iam user remove-role user@example.com admin + +# Remove a role by ID +qcloud iam user remove-role user@example.com 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Remove multiple roles at once +qcloud iam user remove-role user@example.com admin viewer`, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return err + } + accountID, err := s.AccountID() + if err != nil { + return err + } + + user, err := resolveUser(cmd, client, accountID, args[0]) + if err != nil { + return err + } + + roleIDs, err := resolveRoleIDs(ctx, client, accountID, args[1:]) + if err != nil { + return err + } + + _, err = client.IAM().AssignUserRoles(ctx, &iamv1.AssignUserRolesRequest{ + AccountId: accountID, + UserId: user.GetId(), + RoleIdsToDelete: roleIDs, + }) + if err != nil { + return fmt.Errorf("failed to remove roles: %w", err) + } + + rolesResp, err := client.IAM().ListUserRoles(ctx, &iamv1.ListUserRolesRequest{ + AccountId: accountID, + UserId: user.GetId(), + }) + if err != nil { + return fmt.Errorf("failed to list user roles: %w", err) + } + + if s.Config.JSONOutput() { + return output.PrintJSON(cmd.OutOrStdout(), rolesResp) + } + + w := cmd.OutOrStdout() + fmt.Fprintf(w, "Roles for %s:\n", user.GetEmail()) + printRoles(w, rolesResp.GetRoles()) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/output/iam.go b/internal/cmd/output/iam.go new file mode 100644 index 0000000..60a42ba --- /dev/null +++ b/internal/cmd/output/iam.go @@ -0,0 +1,18 @@ +package output + +import ( + "strings" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" +) + +// UserStatus formats an iamv1.UserStatus enum for display. +func UserStatus(x iamv1.UserStatus) string { + return strings.TrimPrefix(x.String(), "USER_STATUS_") +} + +// AccountInviteStatus formats an accountv1.AccountInviteStatus enum for display. +func AccountInviteStatus(x accountv1.AccountInviteStatus) string { + return strings.TrimPrefix(x.String(), "ACCOUNT_INVITE_STATUS_") +} diff --git a/internal/qcloudapi/client.go b/internal/qcloudapi/client.go index 8b7bd4e..0a67c5f 100644 --- a/internal/qcloudapi/client.go +++ b/internal/qcloudapi/client.go @@ -7,12 +7,14 @@ import ( "google.golang.org/grpc/credentials" "google.golang.org/grpc/metadata" + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" authv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/auth/v1" bookingv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/booking/v1" clusterauthv2 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/auth/v2" backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1" hybridv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/hybrid/v1" + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" monitoringv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/monitoring/v1" platformv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/platform/v1" ) @@ -28,6 +30,8 @@ type Client struct { hybrid hybridv1.HybridCloudServiceClient monitoring monitoringv1.MonitoringServiceClient auth authv1.AuthServiceClient + iam iamv1.IAMServiceClient + account accountv1.AccountServiceClient } // New creates a new gRPC client connected to the given endpoint with the given API key. @@ -60,6 +64,8 @@ func newFromConn(conn *grpc.ClientConn) *Client { hybrid: hybridv1.NewHybridCloudServiceClient(conn), monitoring: monitoringv1.NewMonitoringServiceClient(conn), auth: authv1.NewAuthServiceClient(conn), + iam: iamv1.NewIAMServiceClient(conn), + account: accountv1.NewAccountServiceClient(conn), } } @@ -103,6 +109,16 @@ func (c *Client) Auth() authv1.AuthServiceClient { return c.auth } +// IAM returns the IAMService gRPC client. +func (c *Client) IAM() iamv1.IAMServiceClient { + return c.iam +} + +// Account returns the AccountService gRPC client. +func (c *Client) Account() accountv1.AccountServiceClient { + return c.account +} + // Close closes the underlying gRPC connection. func (c *Client) Close() error { return c.conn.Close() diff --git a/internal/testutil/fake_account.go b/internal/testutil/fake_account.go new file mode 100644 index 0000000..76e3de0 --- /dev/null +++ b/internal/testutil/fake_account.go @@ -0,0 +1,43 @@ +package testutil + +import ( + "context" + + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" +) + +// FakeAccountService is a test fake that implements AccountServiceServer. +// Use the *Calls fields to configure responses and inspect captured requests. +type FakeAccountService struct { + accountv1.UnimplementedAccountServiceServer + + ListAccountInvitesCalls MethodSpy[*accountv1.ListAccountInvitesRequest, *accountv1.ListAccountInvitesResponse] + GetAccountInviteCalls MethodSpy[*accountv1.GetAccountInviteRequest, *accountv1.GetAccountInviteResponse] + CreateAccountInviteCalls MethodSpy[*accountv1.CreateAccountInviteRequest, *accountv1.CreateAccountInviteResponse] + DeleteAccountInviteCalls MethodSpy[*accountv1.DeleteAccountInviteRequest, *accountv1.DeleteAccountInviteResponse] +} + +// ListAccountInvites records the call and dispatches via ListAccountInvitesCalls. +func (f *FakeAccountService) ListAccountInvites(ctx context.Context, req *accountv1.ListAccountInvitesRequest) (*accountv1.ListAccountInvitesResponse, error) { + f.ListAccountInvitesCalls.record(req) + return f.ListAccountInvitesCalls.dispatch(ctx, req, f.UnimplementedAccountServiceServer.ListAccountInvites) +} + +// GetAccountInvite records the call and dispatches via GetAccountInviteCalls. +func (f *FakeAccountService) GetAccountInvite(ctx context.Context, req *accountv1.GetAccountInviteRequest) (*accountv1.GetAccountInviteResponse, error) { + f.GetAccountInviteCalls.record(req) + return f.GetAccountInviteCalls.dispatch(ctx, req, f.UnimplementedAccountServiceServer.GetAccountInvite) +} + +// CreateAccountInvite records the call and dispatches via CreateAccountInviteCalls. +func (f *FakeAccountService) CreateAccountInvite(ctx context.Context, req *accountv1.CreateAccountInviteRequest) (*accountv1.CreateAccountInviteResponse, error) { + f.CreateAccountInviteCalls.record(req) + return f.CreateAccountInviteCalls.dispatch(ctx, req, f.UnimplementedAccountServiceServer.CreateAccountInvite) +} + +// DeleteAccountInvite records the call and dispatches via DeleteAccountInviteCalls. +func (f *FakeAccountService) DeleteAccountInvite(ctx context.Context, req *accountv1.DeleteAccountInviteRequest) (*accountv1.DeleteAccountInviteResponse, error) { + f.DeleteAccountInviteCalls.record(req) + return f.DeleteAccountInviteCalls.dispatch(ctx, req, f.UnimplementedAccountServiceServer.DeleteAccountInvite) +} + diff --git a/internal/testutil/fake_iam.go b/internal/testutil/fake_iam.go new file mode 100644 index 0000000..f4d2e2e --- /dev/null +++ b/internal/testutil/fake_iam.go @@ -0,0 +1,49 @@ +package testutil + +import ( + "context" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" +) + +// FakeIAMService is a test fake that implements IAMServiceServer. +// Use the *Calls fields to configure responses and inspect captured requests. +type FakeIAMService struct { + iamv1.UnimplementedIAMServiceServer + + GetAuthenticatedUserCalls MethodSpy[*iamv1.GetAuthenticatedUserRequest, *iamv1.GetAuthenticatedUserResponse] + ListUsersCalls MethodSpy[*iamv1.ListUsersRequest, *iamv1.ListUsersResponse] + ListUserRolesCalls MethodSpy[*iamv1.ListUserRolesRequest, *iamv1.ListUserRolesResponse] + AssignUserRolesCalls MethodSpy[*iamv1.AssignUserRolesRequest, *iamv1.AssignUserRolesResponse] + ListRolesCalls MethodSpy[*iamv1.ListRolesRequest, *iamv1.ListRolesResponse] +} + +// GetAuthenticatedUser records the call and dispatches via GetAuthenticatedUserCalls. +func (f *FakeIAMService) GetAuthenticatedUser(ctx context.Context, req *iamv1.GetAuthenticatedUserRequest) (*iamv1.GetAuthenticatedUserResponse, error) { + f.GetAuthenticatedUserCalls.record(req) + return f.GetAuthenticatedUserCalls.dispatch(ctx, req, f.UnimplementedIAMServiceServer.GetAuthenticatedUser) +} + +// ListUsers records the call and dispatches via ListUsersCalls. +func (f *FakeIAMService) ListUsers(ctx context.Context, req *iamv1.ListUsersRequest) (*iamv1.ListUsersResponse, error) { + f.ListUsersCalls.record(req) + return f.ListUsersCalls.dispatch(ctx, req, f.UnimplementedIAMServiceServer.ListUsers) +} + +// ListUserRoles records the call and dispatches via ListUserRolesCalls. +func (f *FakeIAMService) ListUserRoles(ctx context.Context, req *iamv1.ListUserRolesRequest) (*iamv1.ListUserRolesResponse, error) { + f.ListUserRolesCalls.record(req) + return f.ListUserRolesCalls.dispatch(ctx, req, f.UnimplementedIAMServiceServer.ListUserRoles) +} + +// AssignUserRoles records the call and dispatches via AssignUserRolesCalls. +func (f *FakeIAMService) AssignUserRoles(ctx context.Context, req *iamv1.AssignUserRolesRequest) (*iamv1.AssignUserRolesResponse, error) { + f.AssignUserRolesCalls.record(req) + return f.AssignUserRolesCalls.dispatch(ctx, req, f.UnimplementedIAMServiceServer.AssignUserRoles) +} + +// ListRoles records the call and dispatches via ListRolesCalls. +func (f *FakeIAMService) ListRoles(ctx context.Context, req *iamv1.ListRolesRequest) (*iamv1.ListRolesResponse, error) { + f.ListRolesCalls.record(req) + return f.ListRolesCalls.dispatch(ctx, req, f.UnimplementedIAMServiceServer.ListRoles) +} diff --git a/internal/testutil/server.go b/internal/testutil/server.go index 6c0d025..1824dc1 100644 --- a/internal/testutil/server.go +++ b/internal/testutil/server.go @@ -11,12 +11,14 @@ import ( "google.golang.org/grpc/metadata" "google.golang.org/grpc/test/bufconn" + accountv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/account/v1" authv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/auth/v1" bookingv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/booking/v1" clusterauthv2 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/auth/v2" backupv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/backup/v1" clusterv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/cluster/v1" hybridv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/hybrid/v1" + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" monitoringv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/monitoring/v1" platformv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/platform/v1" @@ -63,6 +65,8 @@ type TestEnv struct { HybridServer *FakeHybridService MonitoringServer *FakeMonitoringService AuthServer *FakeAuthService + IAMServer *FakeIAMService + AccountServer *FakeAccountService Capture *RequestCapture Cleanup func() } @@ -114,6 +118,8 @@ func newBaseTestEnv(t *testing.T, cfg *envConfig) *TestEnv { fakeHybrid := &FakeHybridService{} fakeMonitoring := &FakeMonitoringService{} fakeAuth := &FakeAuthService{} + fakeIAM := &FakeIAMService{} + fakeAccount := &FakeAccountService{} capture := &RequestCapture{} // Start gRPC server on bufconn. @@ -127,6 +133,8 @@ func newBaseTestEnv(t *testing.T, cfg *envConfig) *TestEnv { hybridv1.RegisterHybridCloudServiceServer(srv, fakeHybrid) monitoringv1.RegisterMonitoringServiceServer(srv, fakeMonitoring) authv1.RegisterAuthServiceServer(srv, fakeAuth) + iamv1.RegisterIAMServiceServer(srv, fakeIAM) + accountv1.RegisterAccountServiceServer(srv, fakeAccount) go func() { _ = srv.Serve(lis) @@ -178,6 +186,8 @@ func newBaseTestEnv(t *testing.T, cfg *envConfig) *TestEnv { HybridServer: fakeHybrid, MonitoringServer: fakeMonitoring, AuthServer: fakeAuth, + IAMServer: fakeIAM, + AccountServer: fakeAccount, Capture: capture, Cleanup: cleanup, }