diff --git a/internal/cmd/completion/iam.go b/internal/cmd/completion/iam.go new file mode 100644 index 0000000..9e31243 --- /dev/null +++ b/internal/cmd/completion/iam.go @@ -0,0 +1,71 @@ +package completion + +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" +) + +// 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) { + 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().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.GetId()+"\t"+r.GetName()) + } + return completions, cobra.ShellCompDirectiveNoFileComp + } +} + +// PermissionCompletion returns a completion function for the --permission flag. +func PermissionCompletion(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().ListPermissions(ctx, &iamv1.ListPermissionsRequest{ + AccountId: accountID, + }) + if err != nil { + return nil, cobra.ShellCompDirectiveError + } + + completions := make([]string, 0, len(resp.GetPermissions())) + for _, p := range resp.GetPermissions() { + completions = append(completions, p.GetValue()+"\t"+p.GetCategory()) + } + return completions, cobra.ShellCompDirectiveNoFileComp + } +} diff --git a/internal/cmd/iam/completion_test.go b/internal/cmd/iam/completion_test.go new file mode 100644 index 0000000..bad5c27 --- /dev/null +++ b/internal/cmd/iam/completion_test.go @@ -0,0 +1,102 @@ +package iam_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestRoleIDCompletion_Describe(t *testing.T) { + env := testutil.NewTestEnv(t) + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{ + {Id: "role-uuid-1", Name: "Admin"}, + {Id: "role-uuid-2", Name: "Viewer"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "role", "describe", "") + require.NoError(t, err) + assert.Contains(t, stdout, "role-uuid-1") + assert.Contains(t, stdout, "Admin") + assert.Contains(t, stdout, "role-uuid-2") + assert.Contains(t, stdout, "Viewer") +} + +func TestRoleIDCompletion_Delete(t *testing.T) { + env := testutil.NewTestEnv(t) + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{ + {Id: "role-uuid-1", Name: "Admin"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "role", "delete", "") + require.NoError(t, err) + assert.Contains(t, stdout, "role-uuid-1") + assert.Contains(t, stdout, "Admin") +} + +func TestRoleIDCompletion_AssignPermission(t *testing.T) { + env := testutil.NewTestEnv(t) + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{ + {Id: "role-uuid-1", Name: "Custom Role"}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "role", "assign-permission", "") + require.NoError(t, err) + assert.Contains(t, stdout, "role-uuid-1") + assert.Contains(t, stdout, "Custom Role") +} + +func TestPermissionCompletion_Create(t *testing.T) { + env := testutil.NewTestEnv(t) + env.IAMServer.ListPermissionsCalls.Returns(&iamv1.ListPermissionsResponse{ + Permissions: []*iamv1.Permission{ + {Value: "read:clusters", Category: new("Cluster")}, + {Value: "write:backups", Category: new("Backup")}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "role", "create", "--name", "test", "--permission", "") + require.NoError(t, err) + assert.Contains(t, stdout, "read:clusters") + assert.Contains(t, stdout, "Cluster") + assert.Contains(t, stdout, "write:backups") + assert.Contains(t, stdout, "Backup") +} + +func TestPermissionCompletion_AssignPermission(t *testing.T) { + env := testutil.NewTestEnv(t) + env.IAMServer.ListPermissionsCalls.Returns(&iamv1.ListPermissionsResponse{ + Permissions: []*iamv1.Permission{ + {Value: "read:clusters", Category: new("Cluster")}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "role", "assign-permission", "some-role-id", "--permission", "") + require.NoError(t, err) + assert.Contains(t, stdout, "read:clusters") + assert.Contains(t, stdout, "Cluster") +} + +func TestPermissionCompletion_RemovePermission(t *testing.T) { + env := testutil.NewTestEnv(t) + env.IAMServer.ListPermissionsCalls.Returns(&iamv1.ListPermissionsResponse{ + Permissions: []*iamv1.Permission{ + {Value: "write:backups", Category: new("Backup")}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "__complete", "iam", "role", "remove-permission", "some-role-id", "--permission", "") + require.NoError(t, err) + assert.Contains(t, stdout, "write:backups") + assert.Contains(t, stdout, "Backup") +} diff --git a/internal/cmd/iam/iam.go b/internal/cmd/iam/iam.go index 52545d5..c670b84 100644 --- a/internal/cmd/iam/iam.go +++ b/internal/cmd/iam/iam.go @@ -14,6 +14,10 @@ func NewCommand(s *state.State) *cobra.Command { Long: `Manage IAM resources for the Qdrant Cloud account.`, Args: cobra.NoArgs, } - cmd.AddCommand(newKeyCommand(s)) + cmd.AddCommand( + newKeyCommand(s), + newRoleCommand(s), + newPermissionCommand(s), + ) return cmd } diff --git a/internal/cmd/iam/permission.go b/internal/cmd/iam/permission.go new file mode 100644 index 0000000..1c3629a --- /dev/null +++ b/internal/cmd/iam/permission.go @@ -0,0 +1,21 @@ +package iam + +import ( + "github.com/spf13/cobra" + + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newPermissionCommand(s *state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "permission", + Short: "Manage permissions in Qdrant Cloud", + Long: `Manage permissions for the Qdrant Cloud account. + +Permissions represent individual access rights that can be assigned to roles. +Use these commands to discover which permissions are available in the system.`, + Args: cobra.NoArgs, + } + cmd.AddCommand(newPermissionListCommand(s)) + return cmd +} diff --git a/internal/cmd/iam/permission_list.go b/internal/cmd/iam/permission_list.go new file mode 100644 index 0000000..b2f5bdb --- /dev/null +++ b/internal/cmd/iam/permission_list.go @@ -0,0 +1,57 @@ +package iam + +import ( + "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 newPermissionListCommand(s *state.State) *cobra.Command { + return base.ListCmd[*iamv1.ListPermissionsResponse]{ + Use: "list", + Short: "List all available permissions", + Long: `List all permissions known in the system for the account. + +Permissions are the individual access rights that can be assigned to roles. +Each permission has a value (e.g. "read:clusters") and a category +(e.g. "Cluster").`, + Example: `# List all available permissions +qcloud iam permission list + +# Output as JSON +qcloud iam permission list --json`, + Fetch: func(s *state.State, cmd *cobra.Command) (*iamv1.ListPermissionsResponse, 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 + } + + return client.IAM().ListPermissions(ctx, &iamv1.ListPermissionsRequest{ + AccountId: accountID, + }) + }, + PrintText: func(_ *cobra.Command, w io.Writer, resp *iamv1.ListPermissionsResponse) error { + t := output.NewTable[*iamv1.Permission](w) + t.AddField("PERMISSION", func(v *iamv1.Permission) string { + return v.GetValue() + }) + t.AddField("CATEGORY", func(v *iamv1.Permission) string { + return v.GetCategory() + }) + t.Write(resp.GetPermissions()) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/permission_list_test.go b/internal/cmd/iam/permission_list_test.go new file mode 100644 index 0000000..4b0e447 --- /dev/null +++ b/internal/cmd/iam/permission_list_test.go @@ -0,0 +1,71 @@ +package iam_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestPermissionList_TableOutput(t *testing.T) { + env := testutil.NewTestEnv(t, testutil.WithAccountID("test-account-id")) + + env.IAMServer.ListPermissionsCalls.Returns(&iamv1.ListPermissionsResponse{ + Permissions: []*iamv1.Permission{ + {Value: "read:clusters", Category: new("Cluster")}, + {Value: "write:roles", Category: new("IAM")}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "permission", "list") + require.NoError(t, err) + assert.Contains(t, stdout, "PERMISSION") + assert.Contains(t, stdout, "CATEGORY") + assert.Contains(t, stdout, "read:clusters") + assert.Contains(t, stdout, "Cluster") + assert.Contains(t, stdout, "write:roles") + assert.Contains(t, stdout, "IAM") + + req, ok := env.IAMServer.ListPermissionsCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetAccountId()) +} + +func TestPermissionList_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListPermissionsCalls.Returns(&iamv1.ListPermissionsResponse{ + Permissions: []*iamv1.Permission{ + {Value: "read:clusters", Category: new("Cluster")}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "permission", "list", "--json") + require.NoError(t, err) + + var result struct { + Permissions []struct { + Value string `json:"value"` + Category string `json:"category"` + } `json:"permissions"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + require.Len(t, result.Permissions, 1) + assert.Equal(t, "read:clusters", result.Permissions[0].Value) + assert.Equal(t, "Cluster", result.Permissions[0].Category) +} + +func TestPermissionList_BackendError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListPermissionsCalls.Returns(nil, fmt.Errorf("internal server error")) + + _, _, err := testutil.Exec(t, env, "iam", "permission", "list") + require.Error(t, err) +} diff --git a/internal/cmd/iam/role.go b/internal/cmd/iam/role.go new file mode 100644 index 0000000..d5e7df9 --- /dev/null +++ b/internal/cmd/iam/role.go @@ -0,0 +1,29 @@ +package iam + +import ( + "github.com/spf13/cobra" + + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newRoleCommand(s *state.State) *cobra.Command { + cmd := &cobra.Command{ + Use: "role", + Short: "Manage roles in Qdrant Cloud", + Long: `Manage roles for the Qdrant Cloud account. + +Roles define sets of permissions that control access to resources. There are two +types of roles: system roles (immutable, managed by Qdrant) and custom roles +(created and managed by the account). Use these commands to list, inspect, create, +update, and delete custom roles, as well as manage their permissions.`, + Args: cobra.NoArgs, + } + cmd.AddCommand(newRoleListCommand(s)) + cmd.AddCommand(newRoleDescribeCommand(s)) + cmd.AddCommand(newRoleCreateCommand(s)) + cmd.AddCommand(newRoleUpdateCommand(s)) + cmd.AddCommand(newRoleDeleteCommand(s)) + cmd.AddCommand(newRoleAssignPermissionCommand(s)) + cmd.AddCommand(newRoleRemovePermissionCommand(s)) + return cmd +} diff --git a/internal/cmd/iam/role_assign_permission.go b/internal/cmd/iam/role_assign_permission.go new file mode 100644 index 0000000..d13547d --- /dev/null +++ b/internal/cmd/iam/role_assign_permission.go @@ -0,0 +1,95 @@ +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/completion" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newRoleAssignPermissionCommand(s *state.State) *cobra.Command { + return base.Cmd{ + ValidArgsFunction: completion.RoleIDCompletion(s), + Long: `Add permissions to a custom role. + +Fetches the role's current permissions, merges the new ones (deduplicating), +and updates the role. Use "qcloud iam permission list" to see available +permissions.`, + Example: `# Add a single permission +qcloud iam role assign-permission 7b2ea926-724b-4de2-b73a-8675c42a6ebe --permission read:clusters + +# Add multiple permissions +qcloud iam role assign-permission 7b2ea926-724b-4de2-b73a-8675c42a6ebe \ + --permission read:clusters --permission read:backups`, + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "assign-permission ", + Short: "Add permissions to a role", + Args: util.ExactArgs(1, "a role ID"), + } + cmd.Flags().StringSlice("permission", nil, "Permission to add (repeatable)") + _ = cmd.MarkFlagRequired("permission") + _ = cmd.RegisterFlagCompletionFunc("permission", completion.PermissionCompletion(s)) + return cmd + }, + 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 + } + + resp, err := client.IAM().GetRole(ctx, &iamv1.GetRoleRequest{ + AccountId: accountID, + RoleId: args[0], + }) + if err != nil { + return fmt.Errorf("failed to get role: %w", err) + } + + role := resp.GetRole() + newPerms, _ := cmd.Flags().GetStringSlice("permission") + + // Build a set of existing permission values for dedup. + existing := make(map[string]bool, len(role.GetPermissions())) + for _, p := range role.GetPermissions() { + existing[p.GetValue()] = true + } + + added := 0 + for _, v := range newPerms { + if !existing[v] { + role.Permissions = append(role.Permissions, &iamv1.Permission{Value: v}) + existing[v] = true + added++ + } + } + + if added == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No new permissions to add.") + return nil + } + + _, err = client.IAM().UpdateRole(ctx, &iamv1.UpdateRoleRequest{ + Role: role, + }) + if err != nil { + return fmt.Errorf("failed to update role: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Added %d permission(s) to role %s.\n", added, args[0]) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/role_assign_permission_test.go b/internal/cmd/iam/role_assign_permission_test.go new file mode 100644 index 0000000..7c6306e --- /dev/null +++ b/internal/cmd/iam/role_assign_permission_test.go @@ -0,0 +1,92 @@ +package iam_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestRoleAssignPermission_AddsNew(t *testing.T) { + env := testutil.NewTestEnv(t, testutil.WithAccountID("test-account-id")) + + env.IAMServer.GetRoleCalls.Returns(&iamv1.GetRoleResponse{ + Role: &iamv1.Role{ + Id: "role-abc", + Name: "Test", + AccountId: "test-account-id", + RoleType: iamv1.RoleType_ROLE_TYPE_CUSTOM, + Permissions: []*iamv1.Permission{ + {Value: "read:clusters"}, + }, + }, + }, nil) + + env.IAMServer.UpdateRoleCalls.Returns(&iamv1.UpdateRoleResponse{ + Role: &iamv1.Role{Id: "role-abc"}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "assign-permission", "role-abc", + "--permission", "write:clusters", + ) + require.NoError(t, err) + assert.Contains(t, stdout, "Added 1 permission(s)") + + req, ok := env.IAMServer.UpdateRoleCalls.Last() + require.True(t, ok) + perms := req.GetRole().GetPermissions() + require.Len(t, perms, 2) + assert.Equal(t, "read:clusters", perms[0].GetValue()) + assert.Equal(t, "write:clusters", perms[1].GetValue()) +} + +func TestRoleAssignPermission_Deduplicates(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.GetRoleCalls.Returns(&iamv1.GetRoleResponse{ + Role: &iamv1.Role{ + Id: "role-abc", + RoleType: iamv1.RoleType_ROLE_TYPE_CUSTOM, + Permissions: []*iamv1.Permission{ + {Value: "read:clusters"}, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "assign-permission", "role-abc", + "--permission", "read:clusters", + ) + require.NoError(t, err) + assert.Contains(t, stdout, "No new permissions to add") + assert.Equal(t, 0, env.IAMServer.UpdateRoleCalls.Count()) +} + +func TestRoleAssignPermission_BackendError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.GetRoleCalls.Returns(nil, fmt.Errorf("not found")) + + _, _, err := testutil.Exec(t, env, "iam", "role", "assign-permission", "role-abc", + "--permission", "read:clusters", + ) + require.Error(t, err) +} + +func TestRoleAssignPermission_MissingArg(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "role", "assign-permission") + require.Error(t, err) +} + +func TestRoleAssignPermission_MissingPermission(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "role", "assign-permission", "role-abc") + require.Error(t, err) +} diff --git a/internal/cmd/iam/role_create.go b/internal/cmd/iam/role_create.go new file mode 100644 index 0000000..4ef5aae --- /dev/null +++ b/internal/cmd/iam/role_create.go @@ -0,0 +1,82 @@ +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/completion" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newRoleCreateCommand(s *state.State) *cobra.Command { + return base.CreateCmd[*iamv1.Role]{ + Long: `Create a new custom role for the account. + +Custom roles allow fine-grained access control by combining specific permissions. +Use "qcloud iam permission list" to see available permissions.`, + Example: `# Create a role with specific permissions +qcloud iam role create --name "Cluster Viewer" --permission read:clusters --permission read:cluster-endpoints + +# Create a role with a description +qcloud iam role create --name "Backup Manager" --description "Can manage backups" \ + --permission read:clusters --permission read:backups --permission write:backups`, + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "create", + Short: "Create a custom role", + Args: cobra.NoArgs, + } + cmd.Flags().String("name", "", "Name of the role (4-64 characters)") + cmd.Flags().String("description", "", "Description of the role") + cmd.Flags().StringSlice("permission", nil, "Permission to assign (repeatable)") + _ = cmd.MarkFlagRequired("name") + _ = cmd.MarkFlagRequired("permission") + _ = cmd.RegisterFlagCompletionFunc("permission", completion.PermissionCompletion(s)) + return cmd + }, + Run: func(s *state.State, cmd *cobra.Command, args []string) (*iamv1.Role, 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 + } + + name, _ := cmd.Flags().GetString("name") + description, _ := cmd.Flags().GetString("description") + permValues, _ := cmd.Flags().GetStringSlice("permission") + + permissions := make([]*iamv1.Permission, len(permValues)) + for i, v := range permValues { + permissions[i] = &iamv1.Permission{Value: v} + } + + resp, err := client.IAM().CreateRole(ctx, &iamv1.CreateRoleRequest{ + Role: &iamv1.Role{ + AccountId: accountID, + Name: name, + Description: description, + RoleType: iamv1.RoleType_ROLE_TYPE_CUSTOM, + Permissions: permissions, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to create role: %w", err) + } + + return resp.GetRole(), nil + }, + PrintResource: func(_ *cobra.Command, out io.Writer, role *iamv1.Role) { + fmt.Fprintf(out, "Role %s (%s) created.\n", role.GetId(), role.GetName()) + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/role_create_test.go b/internal/cmd/iam/role_create_test.go new file mode 100644 index 0000000..e51dde2 --- /dev/null +++ b/internal/cmd/iam/role_create_test.go @@ -0,0 +1,102 @@ +package iam_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestRoleCreate_Success(t *testing.T) { + env := testutil.NewTestEnv(t, testutil.WithAccountID("test-account-id")) + + env.IAMServer.CreateRoleCalls.Returns(&iamv1.CreateRoleResponse{ + Role: &iamv1.Role{ + Id: "new-role-id", + Name: "Cluster Viewer", + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "create", + "--name", "Cluster Viewer", + "--description", "Read-only cluster access", + "--permission", "read:clusters", + "--permission", "read:backups", + ) + require.NoError(t, err) + assert.Contains(t, stdout, "new-role-id") + assert.Contains(t, stdout, "Cluster Viewer") + assert.Contains(t, stdout, "created") + + req, ok := env.IAMServer.CreateRoleCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetRole().GetAccountId()) + assert.Equal(t, "Cluster Viewer", req.GetRole().GetName()) + assert.Equal(t, "Read-only cluster access", req.GetRole().GetDescription()) + assert.Equal(t, iamv1.RoleType_ROLE_TYPE_CUSTOM, req.GetRole().GetRoleType()) + require.Len(t, req.GetRole().GetPermissions(), 2) + assert.Equal(t, "read:clusters", req.GetRole().GetPermissions()[0].GetValue()) + assert.Equal(t, "read:backups", req.GetRole().GetPermissions()[1].GetValue()) +} + +func TestRoleCreate_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.CreateRoleCalls.Returns(&iamv1.CreateRoleResponse{ + Role: &iamv1.Role{ + Id: "json-role-id", + Name: "JSON Role", + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "create", + "--name", "JSON Role", + "--permission", "read:clusters", + "--json", + ) + require.NoError(t, err) + + var result struct { + ID string `json:"id"` + Name string `json:"name"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.Equal(t, "json-role-id", result.ID) + assert.Equal(t, "JSON Role", result.Name) +} + +func TestRoleCreate_BackendError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.CreateRoleCalls.Returns(nil, fmt.Errorf("permission denied")) + + _, _, err := testutil.Exec(t, env, "iam", "role", "create", + "--name", "Test", + "--permission", "read:clusters", + ) + require.Error(t, err) +} + +func TestRoleCreate_MissingName(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "role", "create", + "--permission", "read:clusters", + ) + require.Error(t, err) +} + +func TestRoleCreate_MissingPermission(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "role", "create", + "--name", "Test", + ) + require.Error(t, err) +} diff --git a/internal/cmd/iam/role_delete.go b/internal/cmd/iam/role_delete.go new file mode 100644 index 0000000..9a7c604 --- /dev/null +++ b/internal/cmd/iam/role_delete.go @@ -0,0 +1,68 @@ +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/completion" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newRoleDeleteCommand(s *state.State) *cobra.Command { + return base.Cmd{ + Long: `Delete a custom role from the account. + +Only custom roles can be deleted. System roles are managed by Qdrant and cannot +be removed.`, + Example: `# Delete a role (with confirmation prompt) +qcloud iam role delete 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Delete without confirmation +qcloud iam role delete 7b2ea926-724b-4de2-b73a-8675c42a6ebe --force`, + ValidArgsFunction: completion.RoleIDCompletion(s), + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete ", + Short: "Delete a custom role", + Args: util.ExactArgs(1, "a role ID"), + } + cmd.Flags().BoolP("force", "f", false, "Skip confirmation prompt") + return cmd + }, + Run: func(s *state.State, cmd *cobra.Command, args []string) error { + ctx := cmd.Context() + force, _ := cmd.Flags().GetBool("force") + + if !util.ConfirmAction(force, cmd.ErrOrStderr(), fmt.Sprintf("Delete role %s?", args[0])) { + fmt.Fprintln(cmd.OutOrStdout(), "Aborted.") + return nil + } + + client, err := s.Client(ctx) + if err != nil { + return err + } + + accountID, err := s.AccountID() + if err != nil { + return err + } + + _, err = client.IAM().DeleteRole(ctx, &iamv1.DeleteRoleRequest{ + AccountId: accountID, + RoleId: args[0], + }) + if err != nil { + return fmt.Errorf("failed to delete role: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Role %s deleted.\n", args[0]) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/role_delete_test.go b/internal/cmd/iam/role_delete_test.go new file mode 100644 index 0000000..24d32f8 --- /dev/null +++ b/internal/cmd/iam/role_delete_test.go @@ -0,0 +1,54 @@ +package iam_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestRoleDelete_WithForce(t *testing.T) { + env := testutil.NewTestEnv(t, testutil.WithAccountID("test-account-id")) + + env.IAMServer.DeleteRoleCalls.Returns(&iamv1.DeleteRoleResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "delete", "role-abc", "--force") + require.NoError(t, err) + assert.Contains(t, stdout, "role-abc") + assert.Contains(t, stdout, "deleted") + + req, ok := env.IAMServer.DeleteRoleCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetAccountId()) + assert.Equal(t, "role-abc", req.GetRoleId()) +} + +func TestRoleDelete_Aborted(t *testing.T) { + env := testutil.NewTestEnv(t) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "delete", "role-abc") + require.NoError(t, err) + assert.Contains(t, stdout, "Aborted.") + assert.Equal(t, 0, env.IAMServer.DeleteRoleCalls.Count()) +} + +func TestRoleDelete_BackendError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.DeleteRoleCalls.Returns(nil, fmt.Errorf("internal server error")) + + _, _, err := testutil.Exec(t, env, "iam", "role", "delete", "role-abc", "--force") + require.Error(t, err) +} + +func TestRoleDelete_MissingArg(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "role", "delete") + require.Error(t, err) +} diff --git a/internal/cmd/iam/role_describe.go b/internal/cmd/iam/role_describe.go new file mode 100644 index 0000000..b816138 --- /dev/null +++ b/internal/cmd/iam/role_describe.go @@ -0,0 +1,75 @@ +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/completion" + "github.com/qdrant/qcloud-cli/internal/cmd/output" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newRoleDescribeCommand(s *state.State) *cobra.Command { + return base.DescribeCmd[*iamv1.Role]{ + Use: "describe ", + Short: "Describe a role", + Long: `Display detailed information about a role, including its name, type, +description, and the full list of assigned permissions.`, + Example: `# Describe a role +qcloud iam role describe 7b2ea926-724b-4de2-b73a-8675c42a6ebe + +# Output as JSON +qcloud iam role describe 7b2ea926-724b-4de2-b73a-8675c42a6ebe --json`, + Args: util.ExactArgs(1, "a role ID"), + ValidArgsFunction: completion.RoleIDCompletion(s), + Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*iamv1.Role, 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().GetRole(ctx, &iamv1.GetRoleRequest{ + AccountId: accountID, + RoleId: args[0], + }) + if err != nil { + return nil, err + } + + return resp.GetRole(), nil + }, + PrintText: func(_ *cobra.Command, w io.Writer, role *iamv1.Role) error { + fmt.Fprintf(w, "ID: %s\n", role.GetId()) + fmt.Fprintf(w, "Name: %s\n", role.GetName()) + fmt.Fprintf(w, "Description: %s\n", role.GetDescription()) + fmt.Fprintf(w, "Type: %s\n", output.RoleType(role.GetRoleType())) + if role.GetCreatedAt() != nil { + fmt.Fprintf(w, "Created: %s\n", output.FullDateTime(role.GetCreatedAt().AsTime())) + } + if role.GetLastModifiedAt() != nil { + fmt.Fprintf(w, "Last Modified: %s\n", output.FullDateTime(role.GetLastModifiedAt().AsTime())) + } + fmt.Fprintf(w, "\nPermissions:\n") + for _, p := range role.GetPermissions() { + if cat := p.GetCategory(); cat != "" { + fmt.Fprintf(w, " %-30s (%s)\n", p.GetValue(), cat) + } else { + fmt.Fprintf(w, " %s\n", p.GetValue()) + } + } + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/role_describe_test.go b/internal/cmd/iam/role_describe_test.go new file mode 100644 index 0000000..4ad7d18 --- /dev/null +++ b/internal/cmd/iam/role_describe_test.go @@ -0,0 +1,94 @@ +package iam_test + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestRoleDescribe_TextOutput(t *testing.T) { + env := testutil.NewTestEnv(t, testutil.WithAccountID("test-account-id")) + + now := time.Now() + env.IAMServer.GetRoleCalls.Returns(&iamv1.GetRoleResponse{ + Role: &iamv1.Role{ + Id: "role-abc", + Name: "Cluster Viewer", + Description: "Read-only access to clusters", + RoleType: iamv1.RoleType_ROLE_TYPE_CUSTOM, + CreatedAt: timestamppb.New(now), + LastModifiedAt: timestamppb.New(now), + Permissions: []*iamv1.Permission{ + {Value: "read:clusters", Category: new("Cluster")}, + {Value: "read:backups", Category: new("Backup")}, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "describe", "role-abc") + require.NoError(t, err) + assert.Contains(t, stdout, "role-abc") + assert.Contains(t, stdout, "Cluster Viewer") + assert.Contains(t, stdout, "Read-only access to clusters") + assert.Contains(t, stdout, "CUSTOM") + assert.Contains(t, stdout, "read:clusters") + assert.Contains(t, stdout, "read:backups") + assert.Contains(t, stdout, "Cluster") + assert.Contains(t, stdout, "Backup") + + req, ok := env.IAMServer.GetRoleCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetAccountId()) + assert.Equal(t, "role-abc", req.GetRoleId()) +} + +func TestRoleDescribe_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.GetRoleCalls.Returns(&iamv1.GetRoleResponse{ + Role: &iamv1.Role{ + Id: "role-json", + Name: "Test", + RoleType: iamv1.RoleType_ROLE_TYPE_CUSTOM, + Permissions: []*iamv1.Permission{ + {Value: "read:clusters"}, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "describe", "role-json", "--json") + require.NoError(t, err) + + var result struct { + ID string `json:"id"` + Name string `json:"name"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.Equal(t, "role-json", result.ID) + assert.Equal(t, "Test", result.Name) +} + +func TestRoleDescribe_BackendError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.GetRoleCalls.Returns(nil, fmt.Errorf("not found")) + + _, _, err := testutil.Exec(t, env, "iam", "role", "describe", "role-abc") + require.Error(t, err) +} + +func TestRoleDescribe_MissingArg(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "role", "describe") + require.Error(t, err) +} diff --git a/internal/cmd/iam/role_list.go b/internal/cmd/iam/role_list.go new file mode 100644 index 0000000..41bb287 --- /dev/null +++ b/internal/cmd/iam/role_list.go @@ -0,0 +1,69 @@ +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 newRoleListCommand(s *state.State) *cobra.Command { + return base.ListCmd[*iamv1.ListRolesResponse]{ + Use: "list", + Short: "List all roles", + Long: `List all roles for the account, including both system and custom roles. + +System roles are managed by Qdrant and cannot be modified. Custom roles are +created and managed by the account administrator.`, + Example: `# List all roles +qcloud iam role list + +# Output as JSON +qcloud iam role list --json`, + Fetch: func(s *state.State, cmd *cobra.Command) (*iamv1.ListRolesResponse, 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 + } + + return client.IAM().ListRoles(ctx, &iamv1.ListRolesRequest{ + AccountId: accountID, + }) + }, + PrintText: func(_ *cobra.Command, w io.Writer, resp *iamv1.ListRolesResponse) error { + 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.AddField("TYPE", func(v *iamv1.Role) string { + return output.RoleType(v.GetRoleType()) + }) + t.AddField("PERMISSIONS", func(v *iamv1.Role) string { + return fmt.Sprintf("%d", len(v.GetPermissions())) + }) + t.AddField("CREATED", func(v *iamv1.Role) 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/role_list_test.go b/internal/cmd/iam/role_list_test.go new file mode 100644 index 0000000..e46adf9 --- /dev/null +++ b/internal/cmd/iam/role_list_test.go @@ -0,0 +1,107 @@ +package iam_test + +import ( + "encoding/json" + "fmt" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "google.golang.org/protobuf/types/known/timestamppb" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestRoleList_TableOutput(t *testing.T) { + env := testutil.NewTestEnv(t, testutil.WithAccountID("test-account-id")) + + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{ + { + Id: "role-abc", + Name: "Admin", + RoleType: iamv1.RoleType_ROLE_TYPE_SYSTEM, + CreatedAt: timestamppb.New(time.Now().Add(-24 * time.Hour)), + Permissions: []*iamv1.Permission{ + {Value: "read:clusters"}, + {Value: "write:clusters"}, + }, + }, + { + Id: "role-def", + Name: "Viewer", + RoleType: iamv1.RoleType_ROLE_TYPE_CUSTOM, + CreatedAt: timestamppb.New(time.Now().Add(-1 * time.Hour)), + Permissions: []*iamv1.Permission{ + {Value: "read:clusters"}, + }, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "list") + require.NoError(t, err) + assert.Contains(t, stdout, "ID") + assert.Contains(t, stdout, "NAME") + assert.Contains(t, stdout, "TYPE") + assert.Contains(t, stdout, "PERMISSIONS") + assert.Contains(t, stdout, "CREATED") + assert.Contains(t, stdout, "role-abc") + assert.Contains(t, stdout, "Admin") + assert.Contains(t, stdout, "SYSTEM") + assert.Contains(t, stdout, "2") + assert.Contains(t, stdout, "role-def") + assert.Contains(t, stdout, "Viewer") + assert.Contains(t, stdout, "CUSTOM") + + req, ok := env.IAMServer.ListRolesCalls.Last() + require.True(t, ok) + assert.Equal(t, "test-account-id", req.GetAccountId()) +} + +func TestRoleList_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{ + Items: []*iamv1.Role{ + {Id: "role-json", Name: "Test Role", RoleType: iamv1.RoleType_ROLE_TYPE_CUSTOM}, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "list", "--json") + require.NoError(t, err) + + var result struct { + Items []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"items"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + require.Len(t, result.Items, 1) + assert.Equal(t, "role-json", result.Items[0].ID) + assert.Equal(t, "Test Role", result.Items[0].Name) +} + +func TestRoleList_Empty(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListRolesCalls.Returns(&iamv1.ListRolesResponse{}, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "list") + require.NoError(t, err) + assert.Contains(t, stdout, "ID") + assert.Contains(t, stdout, "NAME") +} + +func TestRoleList_BackendError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.ListRolesCalls.Returns(nil, fmt.Errorf("internal server error")) + + _, _, err := testutil.Exec(t, env, "iam", "role", "list") + require.Error(t, err) +} diff --git a/internal/cmd/iam/role_remove_permission.go b/internal/cmd/iam/role_remove_permission.go new file mode 100644 index 0000000..119b8e7 --- /dev/null +++ b/internal/cmd/iam/role_remove_permission.go @@ -0,0 +1,100 @@ +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/completion" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newRoleRemovePermissionCommand(s *state.State) *cobra.Command { + return base.Cmd{ + ValidArgsFunction: completion.RoleIDCompletion(s), + Long: `Remove permissions from a custom role. + +Fetches the role's current permissions, removes the specified ones, and updates +the role. A role must retain at least one permission.`, + Example: `# Remove a single permission +qcloud iam role remove-permission 7b2ea926-724b-4de2-b73a-8675c42a6ebe --permission read:clusters + +# Remove multiple permissions +qcloud iam role remove-permission 7b2ea926-724b-4de2-b73a-8675c42a6ebe \ + --permission read:clusters --permission read:backups`, + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "remove-permission ", + Short: "Remove permissions from a role", + Args: util.ExactArgs(1, "a role ID"), + } + cmd.Flags().StringSlice("permission", nil, "Permission to remove (repeatable)") + _ = cmd.MarkFlagRequired("permission") + _ = cmd.RegisterFlagCompletionFunc("permission", completion.PermissionCompletion(s)) + return cmd + }, + 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 + } + + resp, err := client.IAM().GetRole(ctx, &iamv1.GetRoleRequest{ + AccountId: accountID, + RoleId: args[0], + }) + if err != nil { + return fmt.Errorf("failed to get role: %w", err) + } + + role := resp.GetRole() + toRemove, _ := cmd.Flags().GetStringSlice("permission") + + removeSet := make(map[string]bool, len(toRemove)) + for _, v := range toRemove { + removeSet[v] = true + } + + var kept []*iamv1.Permission + removed := 0 + for _, p := range role.GetPermissions() { + if removeSet[p.GetValue()] { + removed++ + } else { + kept = append(kept, p) + } + } + + if removed == 0 { + fmt.Fprintln(cmd.OutOrStdout(), "No matching permissions to remove.") + return nil + } + + if len(kept) == 0 { + return fmt.Errorf("cannot remove all permissions: a role must have at least one permission") + } + + role.Permissions = kept + + _, err = client.IAM().UpdateRole(ctx, &iamv1.UpdateRoleRequest{ + Role: role, + }) + if err != nil { + return fmt.Errorf("failed to update role: %w", err) + } + + fmt.Fprintf(cmd.OutOrStdout(), "Removed %d permission(s) from role %s.\n", removed, args[0]) + return nil + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/role_remove_permission_test.go b/internal/cmd/iam/role_remove_permission_test.go new file mode 100644 index 0000000..998ffd3 --- /dev/null +++ b/internal/cmd/iam/role_remove_permission_test.go @@ -0,0 +1,106 @@ +package iam_test + +import ( + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestRoleRemovePermission_RemovesExisting(t *testing.T) { + env := testutil.NewTestEnv(t, testutil.WithAccountID("test-account-id")) + + env.IAMServer.GetRoleCalls.Returns(&iamv1.GetRoleResponse{ + Role: &iamv1.Role{ + Id: "role-abc", + Name: "Test", + AccountId: "test-account-id", + RoleType: iamv1.RoleType_ROLE_TYPE_CUSTOM, + Permissions: []*iamv1.Permission{ + {Value: "read:clusters"}, + {Value: "write:clusters"}, + }, + }, + }, nil) + + env.IAMServer.UpdateRoleCalls.Returns(&iamv1.UpdateRoleResponse{ + Role: &iamv1.Role{Id: "role-abc"}, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "remove-permission", "role-abc", + "--permission", "write:clusters", + ) + require.NoError(t, err) + assert.Contains(t, stdout, "Removed 1 permission(s)") + + req, ok := env.IAMServer.UpdateRoleCalls.Last() + require.True(t, ok) + perms := req.GetRole().GetPermissions() + require.Len(t, perms, 1) + assert.Equal(t, "read:clusters", perms[0].GetValue()) +} + +func TestRoleRemovePermission_NoMatch(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.GetRoleCalls.Returns(&iamv1.GetRoleResponse{ + Role: &iamv1.Role{ + Id: "role-abc", + RoleType: iamv1.RoleType_ROLE_TYPE_CUSTOM, + Permissions: []*iamv1.Permission{ + {Value: "read:clusters"}, + }, + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "remove-permission", "role-abc", + "--permission", "write:backups", + ) + require.NoError(t, err) + assert.Contains(t, stdout, "No matching permissions to remove") + assert.Equal(t, 0, env.IAMServer.UpdateRoleCalls.Count()) +} + +func TestRoleRemovePermission_CannotRemoveAll(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.GetRoleCalls.Returns(&iamv1.GetRoleResponse{ + Role: &iamv1.Role{ + Id: "role-abc", + RoleType: iamv1.RoleType_ROLE_TYPE_CUSTOM, + Permissions: []*iamv1.Permission{ + {Value: "read:clusters"}, + }, + }, + }, nil) + + _, _, err := testutil.Exec(t, env, "iam", "role", "remove-permission", "role-abc", + "--permission", "read:clusters", + ) + require.Error(t, err) + assert.Contains(t, err.Error(), "at least one permission") + assert.Equal(t, 0, env.IAMServer.UpdateRoleCalls.Count()) +} + +func TestRoleRemovePermission_BackendError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.GetRoleCalls.Returns(nil, fmt.Errorf("not found")) + + _, _, err := testutil.Exec(t, env, "iam", "role", "remove-permission", "role-abc", + "--permission", "read:clusters", + ) + require.Error(t, err) +} + +func TestRoleRemovePermission_MissingArg(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "role", "remove-permission") + require.Error(t, err) +} diff --git a/internal/cmd/iam/role_update.go b/internal/cmd/iam/role_update.go new file mode 100644 index 0000000..65c45f4 --- /dev/null +++ b/internal/cmd/iam/role_update.go @@ -0,0 +1,94 @@ +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/completion" + "github.com/qdrant/qcloud-cli/internal/cmd/util" + "github.com/qdrant/qcloud-cli/internal/state" +) + +func newRoleUpdateCommand(s *state.State) *cobra.Command { + return base.UpdateCmd[*iamv1.Role]{ + Long: `Update the name or description of a custom role. + +Only custom roles can be updated. System roles are managed by Qdrant and cannot +be modified. To change a role's permissions, use the assign-permission and +remove-permission subcommands.`, + Example: `# Rename a role +qcloud iam role update 7b2ea926-724b-4de2-b73a-8675c42a6ebe --name "New Name" + +# Update the description +qcloud iam role update 7b2ea926-724b-4de2-b73a-8675c42a6ebe --description "Updated description"`, + ValidArgsFunction: completion.RoleIDCompletion(s), + BaseCobraCommand: func() *cobra.Command { + cmd := &cobra.Command{ + Use: "update ", + Short: "Update a custom role", + Args: util.ExactArgs(1, "a role ID"), + } + cmd.Flags().String("name", "", "New name for the role") + cmd.Flags().String("description", "", "New description for the role") + return cmd + }, + Fetch: func(s *state.State, cmd *cobra.Command, args []string) (*iamv1.Role, 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().GetRole(ctx, &iamv1.GetRoleRequest{ + AccountId: accountID, + RoleId: args[0], + }) + if err != nil { + return nil, fmt.Errorf("failed to get role: %w", err) + } + + return resp.GetRole(), nil + }, + Update: func(s *state.State, cmd *cobra.Command, role *iamv1.Role) (*iamv1.Role, error) { + ctx := cmd.Context() + client, err := s.Client(ctx) + if err != nil { + return nil, err + } + + if cmd.Flags().Changed("name") { + name, _ := cmd.Flags().GetString("name") + role.Name = name + } + if cmd.Flags().Changed("description") { + description, _ := cmd.Flags().GetString("description") + role.Description = description + } + + resp, err := client.IAM().UpdateRole(ctx, &iamv1.UpdateRoleRequest{ + Role: role, + }) + if err != nil { + return nil, fmt.Errorf("failed to update role: %w", err) + } + + return resp.GetRole(), nil + }, + PrintResource: func(_ *cobra.Command, out io.Writer, role *iamv1.Role) { + if role == nil { + return + } + fmt.Fprintf(out, "Role %s (%s) updated.\n", role.GetId(), role.GetName()) + }, + }.CobraCommand(s) +} diff --git a/internal/cmd/iam/role_update_test.go b/internal/cmd/iam/role_update_test.go new file mode 100644 index 0000000..51934d9 --- /dev/null +++ b/internal/cmd/iam/role_update_test.go @@ -0,0 +1,138 @@ +package iam_test + +import ( + "encoding/json" + "fmt" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" + + "github.com/qdrant/qcloud-cli/internal/testutil" +) + +func TestRoleUpdate_Name(t *testing.T) { + env := testutil.NewTestEnv(t, testutil.WithAccountID("test-account-id")) + + env.IAMServer.GetRoleCalls.Returns(&iamv1.GetRoleResponse{ + Role: &iamv1.Role{ + Id: "role-abc", + Name: "Old Name", + RoleType: iamv1.RoleType_ROLE_TYPE_CUSTOM, + Permissions: []*iamv1.Permission{ + {Value: "read:clusters"}, + }, + }, + }, nil) + + env.IAMServer.UpdateRoleCalls.Returns(&iamv1.UpdateRoleResponse{ + Role: &iamv1.Role{ + Id: "role-abc", + Name: "New Name", + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "update", "role-abc", "--name", "New Name") + require.NoError(t, err) + assert.Contains(t, stdout, "role-abc") + assert.Contains(t, stdout, "New Name") + assert.Contains(t, stdout, "updated") + + req, ok := env.IAMServer.UpdateRoleCalls.Last() + require.True(t, ok) + assert.Equal(t, "New Name", req.GetRole().GetName()) + // Permissions should be preserved from fetch. + require.Len(t, req.GetRole().GetPermissions(), 1) + assert.Equal(t, "read:clusters", req.GetRole().GetPermissions()[0].GetValue()) +} + +func TestRoleUpdate_Description(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.GetRoleCalls.Returns(&iamv1.GetRoleResponse{ + Role: &iamv1.Role{ + Id: "role-abc", + Name: "Test", + RoleType: iamv1.RoleType_ROLE_TYPE_CUSTOM, + Permissions: []*iamv1.Permission{ + {Value: "read:clusters"}, + }, + }, + }, nil) + + env.IAMServer.UpdateRoleCalls.Returns(&iamv1.UpdateRoleResponse{ + Role: &iamv1.Role{ + Id: "role-abc", + Name: "Test", + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "update", "role-abc", "--description", "Updated desc") + require.NoError(t, err) + assert.Contains(t, stdout, "updated") + + req, ok := env.IAMServer.UpdateRoleCalls.Last() + require.True(t, ok) + assert.Equal(t, "Updated desc", req.GetRole().GetDescription()) +} + +func TestRoleUpdate_JSONOutput(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.GetRoleCalls.Returns(&iamv1.GetRoleResponse{ + Role: &iamv1.Role{ + Id: "role-abc", + Name: "Test", + RoleType: iamv1.RoleType_ROLE_TYPE_CUSTOM, + Permissions: []*iamv1.Permission{ + {Value: "read:clusters"}, + }, + }, + }, nil) + + env.IAMServer.UpdateRoleCalls.Returns(&iamv1.UpdateRoleResponse{ + Role: &iamv1.Role{ + Id: "role-abc", + Name: "Renamed", + }, + }, nil) + + stdout, _, err := testutil.Exec(t, env, "iam", "role", "update", "role-abc", "--name", "Renamed", "--json") + require.NoError(t, err) + + var result struct { + ID string `json:"id"` + Name string `json:"name"` + } + require.NoError(t, json.Unmarshal([]byte(stdout), &result)) + assert.Equal(t, "role-abc", result.ID) + assert.Equal(t, "Renamed", result.Name) +} + +func TestRoleUpdate_BackendError(t *testing.T) { + env := testutil.NewTestEnv(t) + + env.IAMServer.GetRoleCalls.Returns(&iamv1.GetRoleResponse{ + Role: &iamv1.Role{ + Id: "role-abc", + RoleType: iamv1.RoleType_ROLE_TYPE_CUSTOM, + Permissions: []*iamv1.Permission{ + {Value: "read:clusters"}, + }, + }, + }, nil) + + env.IAMServer.UpdateRoleCalls.Returns(nil, fmt.Errorf("internal server error")) + + _, _, err := testutil.Exec(t, env, "iam", "role", "update", "role-abc", "--name", "X") + require.Error(t, err) +} + +func TestRoleUpdate_MissingArg(t *testing.T) { + env := testutil.NewTestEnv(t) + + _, _, err := testutil.Exec(t, env, "iam", "role", "update") + require.Error(t, err) +} diff --git a/internal/cmd/output/iam.go b/internal/cmd/output/iam.go new file mode 100644 index 0000000..ec5c9a7 --- /dev/null +++ b/internal/cmd/output/iam.go @@ -0,0 +1,12 @@ +package output + +import ( + "strings" + + iamv1 "github.com/qdrant/qdrant-cloud-public-api/gen/go/qdrant/cloud/iam/v1" +) + +// RoleType formats a RoleType enum for display. +func RoleType(v iamv1.RoleType) string { + return strings.TrimPrefix(v.String(), "ROLE_TYPE_") +} diff --git a/internal/qcloudapi/client.go b/internal/qcloudapi/client.go index 527dfdd..0a67c5f 100644 --- a/internal/qcloudapi/client.go +++ b/internal/qcloudapi/client.go @@ -14,6 +14,7 @@ import ( 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" ) @@ -29,6 +30,7 @@ type Client struct { hybrid hybridv1.HybridCloudServiceClient monitoring monitoringv1.MonitoringServiceClient auth authv1.AuthServiceClient + iam iamv1.IAMServiceClient account accountv1.AccountServiceClient } @@ -62,6 +64,7 @@ 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), } } @@ -106,6 +109,11 @@ 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 diff --git a/internal/testutil/fake_iam.go b/internal/testutil/fake_iam.go new file mode 100644 index 0000000..3009600 --- /dev/null +++ b/internal/testutil/fake_iam.go @@ -0,0 +1,56 @@ +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 + + ListRolesCalls MethodSpy[*iamv1.ListRolesRequest, *iamv1.ListRolesResponse] + GetRoleCalls MethodSpy[*iamv1.GetRoleRequest, *iamv1.GetRoleResponse] + CreateRoleCalls MethodSpy[*iamv1.CreateRoleRequest, *iamv1.CreateRoleResponse] + UpdateRoleCalls MethodSpy[*iamv1.UpdateRoleRequest, *iamv1.UpdateRoleResponse] + DeleteRoleCalls MethodSpy[*iamv1.DeleteRoleRequest, *iamv1.DeleteRoleResponse] + ListPermissionsCalls MethodSpy[*iamv1.ListPermissionsRequest, *iamv1.ListPermissionsResponse] +} + +// 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) +} + +// GetRole records the call and dispatches via GetRoleCalls. +func (f *FakeIAMService) GetRole(ctx context.Context, req *iamv1.GetRoleRequest) (*iamv1.GetRoleResponse, error) { + f.GetRoleCalls.record(req) + return f.GetRoleCalls.dispatch(ctx, req, f.UnimplementedIAMServiceServer.GetRole) +} + +// CreateRole records the call and dispatches via CreateRoleCalls. +func (f *FakeIAMService) CreateRole(ctx context.Context, req *iamv1.CreateRoleRequest) (*iamv1.CreateRoleResponse, error) { + f.CreateRoleCalls.record(req) + return f.CreateRoleCalls.dispatch(ctx, req, f.UnimplementedIAMServiceServer.CreateRole) +} + +// UpdateRole records the call and dispatches via UpdateRoleCalls. +func (f *FakeIAMService) UpdateRole(ctx context.Context, req *iamv1.UpdateRoleRequest) (*iamv1.UpdateRoleResponse, error) { + f.UpdateRoleCalls.record(req) + return f.UpdateRoleCalls.dispatch(ctx, req, f.UnimplementedIAMServiceServer.UpdateRole) +} + +// DeleteRole records the call and dispatches via DeleteRoleCalls. +func (f *FakeIAMService) DeleteRole(ctx context.Context, req *iamv1.DeleteRoleRequest) (*iamv1.DeleteRoleResponse, error) { + f.DeleteRoleCalls.record(req) + return f.DeleteRoleCalls.dispatch(ctx, req, f.UnimplementedIAMServiceServer.DeleteRole) +} + +// ListPermissions records the call and dispatches via ListPermissionsCalls. +func (f *FakeIAMService) ListPermissions(ctx context.Context, req *iamv1.ListPermissionsRequest) (*iamv1.ListPermissionsResponse, error) { + f.ListPermissionsCalls.record(req) + return f.ListPermissionsCalls.dispatch(ctx, req, f.UnimplementedIAMServiceServer.ListPermissions) +} diff --git a/internal/testutil/server.go b/internal/testutil/server.go index ac896df..1824dc1 100644 --- a/internal/testutil/server.go +++ b/internal/testutil/server.go @@ -18,6 +18,7 @@ import ( 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" @@ -64,6 +65,7 @@ type TestEnv struct { HybridServer *FakeHybridService MonitoringServer *FakeMonitoringService AuthServer *FakeAuthService + IAMServer *FakeIAMService AccountServer *FakeAccountService Capture *RequestCapture Cleanup func() @@ -116,6 +118,7 @@ func newBaseTestEnv(t *testing.T, cfg *envConfig) *TestEnv { fakeHybrid := &FakeHybridService{} fakeMonitoring := &FakeMonitoringService{} fakeAuth := &FakeAuthService{} + fakeIAM := &FakeIAMService{} fakeAccount := &FakeAccountService{} capture := &RequestCapture{} @@ -130,6 +133,7 @@ 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() { @@ -182,6 +186,7 @@ func newBaseTestEnv(t *testing.T, cfg *envConfig) *TestEnv { HybridServer: fakeHybrid, MonitoringServer: fakeMonitoring, AuthServer: fakeAuth, + IAMServer: fakeIAM, AccountServer: fakeAccount, Capture: capture, Cleanup: cleanup,