Skip to content

Commit 6e83875

Browse files
authored
feat: add user management commands (#102)
* feat: add user and invite commands * chore: code format * refactor: move tests to their command files for users * refactor: extract duplicated code into functions and reuse resolveUser in the user describe command. * fix: included the user object in the user describe command * refactor: add util.MinimumNArgs function to return a descriptive error if the arguments were not provided properly * chore: code format * fix: bring back the use of DescribeCmd for user describe * feat: add completion for iam user/invite commands * fix: remove user invite command because the api call can only be issued from a cookie authenticated user * fix: remove invite management commands They are not useful while use a management api key, since invites can't be accepted/rejected while using one. * chore: code format * refactor: use --role flags instead of arguments for listing roles on assign/remove role commands to keep consistency with permission management * fix: add api error test cases for user management commands * chore: remove unused lsitUserCompletion function that was only used in one place * chore: remove unused AccountInviteStatus function * chore: code format * refactor: removed errVerb argument from the modifyUserRoles function, it's too much and a generic error can be returned * chore: remove comment regarding errVerb
1 parent 28d3b38 commit 6e83875

18 files changed

Lines changed: 1201 additions & 11 deletions

internal/cmd/completion/iam.go

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,6 +8,34 @@ import (
88
"github.com/qdrant/qcloud-cli/internal/state"
99
)
1010

11+
// RoleCompletion returns a completion function that completes IAM role names
12+
// with their ID as description.
13+
func RoleCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
14+
return func(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) {
15+
ctx := cmd.Context()
16+
client, err := s.Client(ctx)
17+
if err != nil {
18+
return nil, cobra.ShellCompDirectiveError
19+
}
20+
21+
accountID, err := s.AccountID()
22+
if err != nil {
23+
return nil, cobra.ShellCompDirectiveError
24+
}
25+
26+
resp, err := client.IAM().ListRoles(ctx, &iamv1.ListRolesRequest{AccountId: accountID})
27+
if err != nil {
28+
return nil, cobra.ShellCompDirectiveError
29+
}
30+
31+
completions := make([]string, 0, len(resp.GetItems()))
32+
for _, r := range resp.GetItems() {
33+
completions = append(completions, r.GetName()+"\t"+r.GetId())
34+
}
35+
return completions, cobra.ShellCompDirectiveNoFileComp
36+
}
37+
}
38+
1139
// RoleIDCompletion returns a ValidArgsFunction that completes role IDs.
1240
func RoleIDCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
1341
return func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {

internal/cmd/iam/completion.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
package iam
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1"
7+
8+
"github.com/qdrant/qcloud-cli/internal/state"
9+
)
10+
11+
// userCompletion returns a ValidArgsFunction that completes user IDs with
12+
// their email as description. It only completes the first positional argument.
13+
func userCompletion(s *state.State) func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) {
14+
return func(cmd *cobra.Command, args []string, _ string) ([]string, cobra.ShellCompDirective) {
15+
if len(args) > 0 {
16+
return nil, cobra.ShellCompDirectiveNoFileComp
17+
}
18+
ctx := cmd.Context()
19+
client, err := s.Client(ctx)
20+
if err != nil {
21+
return nil, cobra.ShellCompDirectiveError
22+
}
23+
accountID, err := s.AccountID()
24+
if err != nil {
25+
return nil, cobra.ShellCompDirectiveError
26+
}
27+
28+
resp, err := client.IAM().ListUsers(ctx, &iamv1.ListUsersRequest{AccountId: accountID})
29+
if err != nil {
30+
return nil, cobra.ShellCompDirectiveError
31+
}
32+
33+
completions := make([]string, 0, len(resp.GetItems()))
34+
for _, u := range resp.GetItems() {
35+
completions = append(completions, u.GetId()+"\t"+u.GetEmail())
36+
}
37+
return completions, cobra.ShellCompDirectiveNoFileComp
38+
}
39+
}

internal/cmd/iam/completion_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,47 @@ import (
1111
"github.com/qdrant/qcloud-cli/internal/testutil"
1212
)
1313

14+
func TestUserCompletion(t *testing.T) {
15+
env := testutil.NewTestEnv(t)
16+
17+
env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{
18+
Items: []*iamv1.User{
19+
{Id: "user-uuid-1", Email: "alice@example.com"},
20+
{Id: "user-uuid-2", Email: "bob@example.com"},
21+
},
22+
}, nil)
23+
24+
stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "user", "describe", "")
25+
require.NoError(t, err)
26+
assert.Contains(t, stdout, "user-uuid-1")
27+
assert.Contains(t, stdout, "alice@example.com")
28+
assert.Contains(t, stdout, "user-uuid-2")
29+
assert.Contains(t, stdout, "bob@example.com")
30+
}
31+
32+
func TestUserCompletion_StopsAfterFirstArg(t *testing.T) {
33+
env := testutil.NewTestEnv(t)
34+
35+
stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "user", "describe", "user-uuid-1", "")
36+
require.NoError(t, err)
37+
assert.NotContains(t, stdout, "user-uuid")
38+
}
39+
40+
func TestUserThenRoleCompletion_FirstArg(t *testing.T) {
41+
env := testutil.NewTestEnv(t)
42+
43+
env.IAMServer.ListUsersCalls.Returns(&iamv1.ListUsersResponse{
44+
Items: []*iamv1.User{
45+
{Id: "user-uuid-1", Email: "alice@example.com"},
46+
},
47+
}, nil)
48+
49+
stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "user", "assign-role", "")
50+
require.NoError(t, err)
51+
assert.Contains(t, stdout, "user-uuid-1")
52+
assert.Contains(t, stdout, "alice@example.com")
53+
}
54+
1455
func TestRoleIDCompletion_Describe(t *testing.T) {
1556
env := testutil.NewTestEnv(t)
1657
env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{

internal/cmd/iam/iam.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ func NewCommand(s *state.State) *cobra.Command {
1616
}
1717
cmd.AddCommand(
1818
newKeyCommand(s),
19+
newUserCommand(s),
1920
newRoleCommand(s),
2021
newPermissionCommand(s),
2122
)

internal/cmd/iam/iam_test.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,7 @@
1+
package iam_test
2+
3+
// Shared test constants used across iam subcommand test files.
4+
const (
5+
testUserID = "7b2ea926-724b-4de2-b73a-8675c42a6ebe"
6+
testRoleID = "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee"
7+
)

internal/cmd/iam/resolve.go

Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
package iam
2+
3+
import (
4+
"context"
5+
"fmt"
6+
7+
"github.com/spf13/cobra"
8+
9+
iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1"
10+
11+
"github.com/qdrant/qcloud-cli/internal/cmd/output"
12+
"github.com/qdrant/qcloud-cli/internal/cmd/util"
13+
"github.com/qdrant/qcloud-cli/internal/qcloudapi"
14+
"github.com/qdrant/qcloud-cli/internal/state"
15+
)
16+
17+
// resolveUser looks up a user by UUID or email from the account's user list.
18+
func resolveUser(cmd *cobra.Command, client *qcloudapi.Client, accountID, idOrEmail string) (*iamv1.User, error) {
19+
ctx := cmd.Context()
20+
resp, err := client.IAM().ListUsers(ctx, &iamv1.ListUsersRequest{AccountId: accountID})
21+
if err != nil {
22+
return nil, fmt.Errorf("failed to list users: %w", err)
23+
}
24+
for _, u := range resp.GetItems() {
25+
if util.IsUUID(idOrEmail) {
26+
if u.GetId() == idOrEmail {
27+
return u, nil
28+
}
29+
} else {
30+
if u.GetEmail() == idOrEmail {
31+
return u, nil
32+
}
33+
}
34+
}
35+
return nil, fmt.Errorf("user %s not found", idOrEmail)
36+
}
37+
38+
// resolveRoleIDs converts a slice of role names or UUIDs to UUIDs.
39+
// Values that already look like UUIDs are passed through unchanged.
40+
// Non-UUID values are resolved by name via ListRoles.
41+
func resolveRoleIDs(ctx context.Context, client *qcloudapi.Client, accountID string, namesOrIDs []string) ([]string, error) {
42+
if len(namesOrIDs) == 0 {
43+
return nil, nil
44+
}
45+
46+
// Check whether any name resolution is needed.
47+
var needsLookup bool
48+
for _, v := range namesOrIDs {
49+
if !util.IsUUID(v) {
50+
needsLookup = true
51+
break
52+
}
53+
}
54+
55+
var rolesByName map[string]string
56+
if needsLookup {
57+
resp, err := client.IAM().ListRoles(ctx, &iamv1.ListRolesRequest{AccountId: accountID})
58+
if err != nil {
59+
return nil, fmt.Errorf("failed to list roles: %w", err)
60+
}
61+
rolesByName = make(map[string]string, len(resp.GetItems()))
62+
for _, r := range resp.GetItems() {
63+
rolesByName[r.GetName()] = r.GetId()
64+
}
65+
}
66+
67+
ids := make([]string, 0, len(namesOrIDs))
68+
for _, v := range namesOrIDs {
69+
if util.IsUUID(v) {
70+
ids = append(ids, v)
71+
} else {
72+
id, ok := rolesByName[v]
73+
if !ok {
74+
return nil, fmt.Errorf("role %q not found", v)
75+
}
76+
ids = append(ids, id)
77+
}
78+
}
79+
return ids, nil
80+
}
81+
82+
// modifyUserRoles calls AssignUserRoles with the given add/delete IDs, then
83+
// fetches and prints the resulting role list.
84+
func modifyUserRoles(s *state.State, cmd *cobra.Command, client *qcloudapi.Client, accountID string, user *iamv1.User, addIDs, removeIDs []string) error {
85+
ctx := cmd.Context()
86+
87+
_, err := client.IAM().AssignUserRoles(ctx, &iamv1.AssignUserRolesRequest{
88+
AccountId: accountID,
89+
UserId: user.GetId(),
90+
RoleIdsToAdd: addIDs,
91+
RoleIdsToDelete: removeIDs,
92+
})
93+
if err != nil {
94+
return fmt.Errorf("failed to modify roles: %w", err)
95+
}
96+
97+
rolesResp, err := client.IAM().ListUserRoles(ctx, &iamv1.ListUserRolesRequest{
98+
AccountId: accountID,
99+
UserId: user.GetId(),
100+
})
101+
if err != nil {
102+
return fmt.Errorf("failed to list user roles: %w", err)
103+
}
104+
105+
if s.Config.JSONOutput() {
106+
return output.PrintJSON(cmd.OutOrStdout(), rolesResp)
107+
}
108+
109+
w := cmd.OutOrStdout()
110+
fmt.Fprintf(w, "Roles for %s:\n", user.GetEmail())
111+
printRoles(w, rolesResp.GetRoles())
112+
return nil
113+
}

internal/cmd/iam/user.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
package iam
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
"github.com/qdrant/qcloud-cli/internal/state"
7+
)
8+
9+
func newUserCommand(s *state.State) *cobra.Command {
10+
cmd := &cobra.Command{
11+
Use: "user",
12+
Short: "Manage users in Qdrant Cloud",
13+
Long: `Manage users in the Qdrant Cloud account.
14+
15+
Provides commands to list users, view user details and assigned roles, and
16+
manage role assignments.`,
17+
Args: cobra.NoArgs,
18+
}
19+
cmd.AddCommand(
20+
newUserListCommand(s),
21+
newUserDescribeCommand(s),
22+
newUserAssignRoleCommand(s),
23+
newUserRemoveRoleCommand(s),
24+
)
25+
return cmd
26+
}
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
package iam
2+
3+
import (
4+
"github.com/spf13/cobra"
5+
6+
"github.com/qdrant/qcloud-cli/internal/cmd/base"
7+
"github.com/qdrant/qcloud-cli/internal/cmd/completion"
8+
"github.com/qdrant/qcloud-cli/internal/cmd/util"
9+
"github.com/qdrant/qcloud-cli/internal/state"
10+
)
11+
12+
func newUserAssignRoleCommand(s *state.State) *cobra.Command {
13+
return base.Cmd{
14+
BaseCobraCommand: func() *cobra.Command {
15+
cmd := &cobra.Command{
16+
Use: "assign-role <user-id-or-email>",
17+
Short: "Assign one or more roles to a user",
18+
Args: util.ExactArgs(1, "a user ID or email"),
19+
}
20+
21+
_ = cmd.Flags().StringSliceP("role", "r", nil, "A role ID or name")
22+
_ = cmd.RegisterFlagCompletionFunc("role", completion.RoleCompletion(s))
23+
return cmd
24+
},
25+
ValidArgsFunction: userCompletion(s),
26+
Long: `Assign one or more roles to a user in the account.
27+
28+
Accepts either a user ID (UUID) or an email address to identify the user.
29+
Each role accepts either a role UUID or a role name, which is
30+
resolved to an ID via the IAM service. Prints the user's resulting roles
31+
after the assignment.`,
32+
Example: `# Assign a role by name
33+
qcloud iam user assign-role user@example.com --role admin
34+
35+
# Assign a role by ID
36+
qcloud iam user assign-role user@example.com --role 7b2ea926-724b-4de2-b73a-8675c42a6ebe
37+
38+
# Assign multiple roles at once
39+
qcloud iam user assign-role user@example.com --role admin --role viewer
40+
41+
# Assign multiple roles at once using comma separated values
42+
qcloud iam user assign-role user@example.com --role admin,viewer`,
43+
Run: func(s *state.State, cmd *cobra.Command, args []string) error {
44+
ctx := cmd.Context()
45+
client, err := s.Client(ctx)
46+
if err != nil {
47+
return err
48+
}
49+
accountID, err := s.AccountID()
50+
if err != nil {
51+
return err
52+
}
53+
user, err := resolveUser(cmd, client, accountID, args[0])
54+
if err != nil {
55+
return err
56+
}
57+
58+
roles, _ := cmd.Flags().GetStringSlice("role")
59+
roleIDs, err := resolveRoleIDs(ctx, client, accountID, roles)
60+
if err != nil {
61+
return err
62+
}
63+
64+
return modifyUserRoles(s, cmd, client, accountID, user, roleIDs, nil)
65+
},
66+
}.CobraCommand(s)
67+
}

0 commit comments

Comments
 (0)