Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
23 commits
Select commit Hold shift + click to select a range
911bd67
feat: add user and invite commands
Davidonium Apr 2, 2026
ec7a3cd
chore: code format
Davidonium Apr 7, 2026
15a5f34
refactor: move tests to their command files for users
Davidonium Apr 7, 2026
5aa44f4
refactor: extract duplicated code into functions and reuse resolveUser
Davidonium Apr 7, 2026
a272ccb
fix: included the user object in the user describe command
Davidonium Apr 7, 2026
8634013
refactor: add util.MinimumNArgs function to return a descriptive error
Davidonium Apr 7, 2026
4094084
chore: code format
Davidonium Apr 7, 2026
cde16b7
fix: bring back the use of DescribeCmd for user describe
Davidonium Apr 7, 2026
7cd0e8e
feat: add completion for iam user/invite commands
Davidonium Apr 7, 2026
5387098
fix: remove user invite command because the api call can only be issu…
Davidonium Apr 7, 2026
675c973
fix: remove invite management commands
Davidonium Apr 7, 2026
db34490
chore: code format
Davidonium Apr 7, 2026
cc07103
refactor: use --role flags instead of arguments for listing roles on
Davidonium Apr 7, 2026
1715f47
fix: add api error test cases for user management commands
Davidonium Apr 7, 2026
4c9a260
Merge branch 'main' into feat/dhernando/user-management
Davidonium Apr 7, 2026
2514c0c
chore: remove unused lsitUserCompletion function that was only used in
Davidonium Apr 7, 2026
45204ca
chore: remove unused AccountInviteStatus function
Davidonium Apr 7, 2026
62061f6
Merge branch 'main' into feat/dhernando/user-management
Davidonium Apr 8, 2026
8507272
Merge branch 'main' into feat/dhernando/user-management
Davidonium Apr 10, 2026
6d45e91
chore: code format
Davidonium Apr 10, 2026
c2ed03f
refactor: removed errVerb argument from the modifyUserRoles function,…
Davidonium Apr 10, 2026
1e83157
chore: remove comment regarding errVerb
Davidonium Apr 10, 2026
7bf9b5a
Merge branch 'main' into feat/dhernando/user-management
Davidonium Apr 10, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
28 changes: 28 additions & 0 deletions internal/cmd/completion/iam.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,34 @@ import (
"github.com/qdrant/qcloud-cli/internal/state"
)

// RoleCompletion returns a completion function that completes IAM role names
// with their ID as description.
func RoleCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
ctx := cmd.Context()
client, err := s.Client(ctx)
if err != nil {
return nil, cobra.ShellCompDirectiveError
}

accountID, err := s.AccountID()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}

resp, err := client.IAM().ListRoles(ctx, &iamv1.ListRolesRequest{AccountId: accountID})
if err != nil {
return nil, cobra.ShellCompDirectiveError
}

completions := make([]string, 0, len(resp.GetItems()))
for _, r := range resp.GetItems() {
completions = append(completions, r.GetName()+"\t"+r.GetId())
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
}

// RoleIDCompletion returns a ValidArgsFunction that completes role IDs.
func RoleIDCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
Expand Down
39 changes: 39 additions & 0 deletions internal/cmd/iam/completion.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package iam

import (
"github.com/spf13/cobra"

iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1"

"github.com/qdrant/qcloud-cli/internal/state"
)

// userCompletion returns a ValidArgsFunction that completes user IDs with
// their email as description. It only completes the first positional argument.
func userCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
return func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
if len(args) > 0 {
return nil, cobra.ShellCompDirectiveNoFileComp
}
ctx := cmd.Context()
client, err := s.Client(ctx)
if err != nil {
return nil, cobra.ShellCompDirectiveError
}
accountID, err := s.AccountID()
if err != nil {
return nil, cobra.ShellCompDirectiveError
}

resp, err := client.IAM().ListUsers(ctx, &iamv1.ListUsersRequest{AccountId: accountID})
if err != nil {
return nil, cobra.ShellCompDirectiveError
}

completions := make([]string, 0, len(resp.GetItems()))
for _, u := range resp.GetItems() {
completions = append(completions, u.GetId()+"\t"+u.GetEmail())
}
return completions, cobra.ShellCompDirectiveNoFileComp
}
}
41 changes: 41 additions & 0 deletions internal/cmd/iam/completion_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,47 @@ import (
"github.com/qdrant/qcloud-cli/internal/testutil"
)

func TestUserCompletion(t *testing.T) {
env := testutil.NewTestEnv(t)

env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{
Items: []*iamv1.User{
{Id: "user-uuid-1", Email: "alice@example.com"},
{Id: "user-uuid-2", Email: "bob@example.com"},
},
}, nil)

stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "user", "describe", "")
require.NoError(t, err)
assert.Contains(t, stdout, "user-uuid-1")
assert.Contains(t, stdout, "alice@example.com")
assert.Contains(t, stdout, "user-uuid-2")
assert.Contains(t, stdout, "bob@example.com")
}

func TestUserCompletion_StopsAfterFirstArg(t *testing.T) {
env := testutil.NewTestEnv(t)

stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "user", "describe", "user-uuid-1", "")
require.NoError(t, err)
assert.NotContains(t, stdout, "user-uuid")
}

func TestUserThenRoleCompletion_FirstArg(t *testing.T) {
env := testutil.NewTestEnv(t)

env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{
Items: []*iamv1.User{
{Id: "user-uuid-1", Email: "alice@example.com"},
},
}, nil)

stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "user", "assign-role", "")
require.NoError(t, err)
assert.Contains(t, stdout, "user-uuid-1")
assert.Contains(t, stdout, "alice@example.com")
}

func TestRoleIDCompletion_Describe(t *testing.T) {
env := testutil.NewTestEnv(t)
env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{
Expand Down
1 change: 1 addition & 0 deletions internal/cmd/iam/iam.go
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ func NewCommand(s *state.State) *cobra.Command {
}
cmd.AddCommand(
newKeyCommand(s),
newUserCommand(s),
newRoleCommand(s),
newPermissionCommand(s),
)
Expand Down
7 changes: 7 additions & 0 deletions internal/cmd/iam/iam_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
package iam_test

// Shared test constants used across iam subcommand test files.
const (
testUserID = "7b2ea926-724b-4de2-b73a-8675c42a6ebe"
testRoleID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
)
113 changes: 113 additions & 0 deletions internal/cmd/iam/resolve.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
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/output"
"github.com/qdrant/qcloud-cli/internal/cmd/util"
"github.com/qdrant/qcloud-cli/internal/qcloudapi"
"github.com/qdrant/qcloud-cli/internal/state"
)

// 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
}

// modifyUserRoles calls AssignUserRoles with the given add/delete IDs, then
// fetches and prints the resulting role list.
func modifyUserRoles(s *state.State, cmd *cobra.Command, client *qcloudapi.Client, accountID string, user *iamv1.User, addIDs, removeIDs []string) error {
ctx := cmd.Context()

_, err := client.IAM().AssignUserRoles(ctx, &iamv1.AssignUserRolesRequest{
AccountId: accountID,
UserId: user.GetId(),
RoleIdsToAdd: addIDs,
RoleIdsToDelete: removeIDs,
})
if err != nil {
return fmt.Errorf("failed to modify 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
}
26 changes: 26 additions & 0 deletions internal/cmd/iam/user.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
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),
)
return cmd
}
67 changes: 67 additions & 0 deletions internal/cmd/iam/user_assign_role.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package iam

import (
"github.com/spf13/cobra"

"github.com/qdrant/qcloud-cli/internal/cmd/base"
"github.com/qdrant/qcloud-cli/internal/cmd/completion"
"github.com/qdrant/qcloud-cli/internal/cmd/util"
"github.com/qdrant/qcloud-cli/internal/state"
)

func newUserAssignRoleCommand(s *state.State) *cobra.Command {
return base.Cmd{
BaseCobraCommand: func() *cobra.Command {
cmd := &cobra.Command{
Use: "assign-role <user-id-or-email>",
Short: "Assign one or more roles to a user",
Args: util.ExactArgs(1, "a user ID or email"),
}

_ = cmd.Flags().StringSliceP("role", "r", nil, "A role ID or name")
_ = cmd.RegisterFlagCompletionFunc("role", completion.RoleCompletion(s))
return cmd
},
ValidArgsFunction: userCompletion(s),
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 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 --role admin

# Assign a role by ID
qcloud iam user assign-role user@example.com --role 7b2ea926-724b-4de2-b73a-8675c42a6ebe

# Assign multiple roles at once
qcloud iam user assign-role user@example.com --role admin --role viewer

# Assign multiple roles at once using comma separated values
qcloud iam user assign-role user@example.com --role 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
}

roles, _ := cmd.Flags().GetStringSlice("role")
roleIDs, err := resolveRoleIDs(ctx, client, accountID, roles)
if err != nil {
return err
}

return modifyUserRoles(s, cmd, client, accountID, user, roleIDs, nil)
},
}.CobraCommand(s)
}
Loading
Loading