diff --git a/README.md b/README.md index 0d2bb24c..4c4ac82c 100644 --- a/README.md +++ b/README.md @@ -40,6 +40,7 @@ D8 provides comprehensive cluster management capabilities: | [**backup**](internal/backup/) | Backup operations | ETCD snapshots, configuration backups, data export | | [**mirror**](internal/mirror/) | Module mirroring | Registry operations, image synchronization, air-gapped deployments | | [**system**](internal/system/) | System diagnostics | Debug info collection, logs analysis, troubleshooting | +| **user-operation** | Local user operations | Request `UserOperation` in `user-authn` (ResetPassword/Reset2FA/Lock/Unlock) | ### 🚀 Module Management @@ -140,6 +141,27 @@ d8 --version --- +## 🧰 User operations (user-authn) + +Request local user operations for Dex static users via `UserOperation` custom resources. + +```bash +# Reset user's 2FA (TOTP) +d8 user-operation reset2fa test-user --timeout 5m + +# Lock user for 10 minutes +d8 user-operation lock test-user --for 10m --timeout 5m + +# Unlock user +d8 user-operation unlock test-user --timeout 5m + +# Reset password (bcrypt hash is required) +HASH="$(echo -n 'Test12345!' | htpasswd -BinC 10 \"\" | cut -d: -f2 | tr -d '\n')" +d8 user-operation reset-password test-user --bcrypt-hash "$HASH" --timeout 5m +``` + +--- + ## 🤝 Contributing We welcome contributions! Here's how you can help: diff --git a/cmd/d8/root.go b/cmd/d8/root.go index 0325c1a3..f2f2753a 100644 --- a/cmd/d8/root.go +++ b/cmd/d8/root.go @@ -45,6 +45,7 @@ import ( status "github.com/deckhouse/deckhouse-cli/internal/status/cmd" system "github.com/deckhouse/deckhouse-cli/internal/system/cmd" "github.com/deckhouse/deckhouse-cli/internal/tools" + useroperation "github.com/deckhouse/deckhouse-cli/internal/useroperation/cmd" "github.com/deckhouse/deckhouse-cli/internal/version" ) @@ -104,6 +105,7 @@ func (r *RootCommand) registerCommands() { r.cmd.AddCommand(data.NewCommand()) r.cmd.AddCommand(mirror.NewCommand()) r.cmd.AddCommand(status.NewCommand()) + r.cmd.AddCommand(useroperation.NewCommand()) r.cmd.AddCommand(tools.NewCommand()) r.cmd.AddCommand(commands.NewVirtualizationCommand()) r.cmd.AddCommand(commands.NewKubectlCommand()) diff --git a/internal/useroperation/cmd/lock.go b/internal/useroperation/cmd/lock.go new file mode 100644 index 00000000..ff110e73 --- /dev/null +++ b/internal/useroperation/cmd/lock.go @@ -0,0 +1,87 @@ +package useroperation + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func newLockCommand() *cobra.Command { + var forStr string + + cmd := &cobra.Command{ + Use: "lock --for 10m", + Short: "Lock local user in Dex for a period of time", + Args: cobra.ExactArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + username := args[0] + if forStr == "" { + return fmt.Errorf("--for is required") + } + // Validate duration format (must be parseable by time.ParseDuration; supports s/m/h). + if _, err := time.ParseDuration(forStr); err != nil { + return fmt.Errorf("invalid --for duration %q: %w", forStr, err) + } + + wf, err := getWaitFlags(cmd) + if err != nil { + return err + } + + dyn, err := newDynamicClient(cmd) + if err != nil { + return err + } + + name := fmt.Sprintf("op-lock-%d", time.Now().Unix()) + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "deckhouse.io/v1", + "kind": "UserOperation", + "metadata": map[string]any{ + "name": name, + }, + "spec": map[string]any{ + "user": username, + "type": "Lock", + "initiatorType": "admin", + "lock": map[string]any{ + "for": forStr, + }, + }, + }, + } + + _, err = createUserOperation(cmd.Context(), dyn, obj) + if err != nil { + return fmt.Errorf("create UserOperation: %w", err) + } + + if !wf.wait { + cmd.Printf("%s\n", name) + return nil + } + + result, err := waitUserOperation(cmd.Context(), dyn, name, wf.timeout) + if err != nil { + return fmt.Errorf("wait UserOperation: %w", err) + } + + phase, _, _ := unstructured.NestedString(result.Object, "status", "phase") + message, _, _ := unstructured.NestedString(result.Object, "status", "message") + if phase == "Failed" { + return fmt.Errorf("Lock failed: %s", message) + } + cmd.Printf("Succeeded: %s\n", name) + return nil + }, + } + + cmd.Flags().StringVar(&forStr, "for", "", "Lock duration (e.g. 30s, 10m, 1h).") + addWaitFlags(cmd, waitFlags{wait: true, timeout: 5 * time.Minute}) + return cmd +} diff --git a/internal/useroperation/cmd/reset2fa.go b/internal/useroperation/cmd/reset2fa.go new file mode 100644 index 00000000..52a50afe --- /dev/null +++ b/internal/useroperation/cmd/reset2fa.go @@ -0,0 +1,73 @@ +package useroperation + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func newReset2FACommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "reset2fa ", + Short: "Reset local user's 2FA (TOTP) in Dex", + Args: cobra.ExactArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + username := args[0] + wf, err := getWaitFlags(cmd) + if err != nil { + return err + } + + dyn, err := newDynamicClient(cmd) + if err != nil { + return err + } + + name := fmt.Sprintf("op-reset2fa-%d", time.Now().Unix()) + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "deckhouse.io/v1", + "kind": "UserOperation", + "metadata": map[string]any{ + "name": name, + }, + "spec": map[string]any{ + "user": username, + "type": "Reset2FA", + "initiatorType": "admin", + }, + }, + } + + _, err = createUserOperation(cmd.Context(), dyn, obj) + if err != nil { + return fmt.Errorf("create UserOperation: %w", err) + } + + if !wf.wait { + cmd.Printf("%s\n", name) + return nil + } + + result, err := waitUserOperation(cmd.Context(), dyn, name, wf.timeout) + if err != nil { + return fmt.Errorf("wait UserOperation: %w", err) + } + + phase, _, _ := unstructured.NestedString(result.Object, "status", "phase") + message, _, _ := unstructured.NestedString(result.Object, "status", "message") + if phase == "Failed" { + return fmt.Errorf("Reset2FA failed: %s", message) + } + cmd.Printf("Succeeded: %s\n", name) + return nil + }, + } + + addWaitFlags(cmd, waitFlags{wait: true, timeout: 5 * time.Minute}) + return cmd +} diff --git a/internal/useroperation/cmd/reset_password.go b/internal/useroperation/cmd/reset_password.go new file mode 100644 index 00000000..39953bfe --- /dev/null +++ b/internal/useroperation/cmd/reset_password.go @@ -0,0 +1,83 @@ +package useroperation + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func newResetPasswordCommand() *cobra.Command { + var bcryptHash string + + cmd := &cobra.Command{ + Use: "reset-password --bcrypt-hash ''", + Aliases: []string{"resetpass"}, + Short: "Reset local user's password in Dex (requires bcrypt hash)", + Args: cobra.ExactArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + username := args[0] + if bcryptHash == "" { + return fmt.Errorf("--bcrypt-hash is required") + } + wf, err := getWaitFlags(cmd) + if err != nil { + return err + } + + dyn, err := newDynamicClient(cmd) + if err != nil { + return err + } + + name := fmt.Sprintf("op-resetpass-%d", time.Now().Unix()) + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "deckhouse.io/v1", + "kind": "UserOperation", + "metadata": map[string]any{ + "name": name, + }, + "spec": map[string]any{ + "user": username, + "type": "ResetPassword", + "initiatorType": "admin", + "resetPassword": map[string]any{ + "newPasswordHash": bcryptHash, + }, + }, + }, + } + + _, err = createUserOperation(cmd.Context(), dyn, obj) + if err != nil { + return fmt.Errorf("create UserOperation: %w", err) + } + + if !wf.wait { + cmd.Printf("%s\n", name) + return nil + } + + result, err := waitUserOperation(cmd.Context(), dyn, name, wf.timeout) + if err != nil { + return fmt.Errorf("wait UserOperation: %w", err) + } + + phase, _, _ := unstructured.NestedString(result.Object, "status", "phase") + message, _, _ := unstructured.NestedString(result.Object, "status", "message") + if phase == "Failed" { + return fmt.Errorf("ResetPassword failed: %s", message) + } + cmd.Printf("Succeeded: %s\n", name) + return nil + }, + } + + cmd.Flags().StringVar(&bcryptHash, "bcrypt-hash", "", "Bcrypt hash of the new password (as produced by htpasswd -BinC 10).") + addWaitFlags(cmd, waitFlags{wait: true, timeout: 5 * time.Minute}) + return cmd +} diff --git a/internal/useroperation/cmd/types.go b/internal/useroperation/cmd/types.go new file mode 100644 index 00000000..74c24f97 --- /dev/null +++ b/internal/useroperation/cmd/types.go @@ -0,0 +1,101 @@ +package useroperation + +import ( + "context" + "fmt" + "time" + + "github.com/spf13/cobra" + metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" + "k8s.io/apimachinery/pkg/runtime/schema" + "k8s.io/apimachinery/pkg/util/wait" + "k8s.io/client-go/dynamic" + + "github.com/deckhouse/deckhouse-cli/internal/utilk8s" +) + +var userOperationGVR = schema.GroupVersionResource{ + Group: "deckhouse.io", + Version: "v1", + Resource: "useroperations", +} + +type waitFlags struct { + wait bool + timeout time.Duration +} + +func addWaitFlags(cmd *cobra.Command, defaults waitFlags) { + cmd.Flags().Bool("wait", defaults.wait, "Wait for UserOperation completion and print result.") + cmd.Flags().Duration("timeout", defaults.timeout, "How long to wait for completion when --wait is enabled.") +} + +func getWaitFlags(cmd *cobra.Command) (waitFlags, error) { + waitVal, err := cmd.Flags().GetBool("wait") + if err != nil { + return waitFlags{}, err + } + timeoutVal, err := cmd.Flags().GetDuration("timeout") + if err != nil { + return waitFlags{}, err + } + return waitFlags{wait: waitVal, timeout: timeoutVal}, nil +} + +func getStringFlag(cmd *cobra.Command, name string) (string, error) { + if cmd.Flags().Lookup(name) != nil { + return cmd.Flags().GetString(name) + } + if cmd.InheritedFlags().Lookup(name) != nil { + return cmd.InheritedFlags().GetString(name) + } + return "", fmt.Errorf("flag %q not found", name) +} + +func newDynamicClient(cmd *cobra.Command) (dynamic.Interface, error) { + kubeconfigPath, err := getStringFlag(cmd, "kubeconfig") + if err != nil { + return nil, fmt.Errorf("failed to get kubeconfig: %w", err) + } + contextName, err := getStringFlag(cmd, "context") + if err != nil { + return nil, fmt.Errorf("failed to get context: %w", err) + } + restConfig, _, err := utilk8s.SetupK8sClientSet(kubeconfigPath, contextName) + if err != nil { + return nil, fmt.Errorf("failed to setup Kubernetes client: %w", err) + } + dyn, err := dynamic.NewForConfig(restConfig) + if err != nil { + return nil, fmt.Errorf("failed to create dynamic client: %w", err) + } + return dyn, nil +} + +func createUserOperation(ctx context.Context, dyn dynamic.Interface, obj *unstructured.Unstructured) (*unstructured.Unstructured, error) { + return dyn.Resource(userOperationGVR).Create(ctx, obj, metav1.CreateOptions{}) +} + +func waitUserOperation(ctx context.Context, dyn dynamic.Interface, name string, timeout time.Duration) (*unstructured.Unstructured, error) { + ctx, cancel := context.WithTimeout(ctx, timeout) + defer cancel() + + var last *unstructured.Unstructured + err := wait.PollUntilContextCancel(ctx, 2*time.Second, true, func(ctx context.Context) (bool, error) { + obj, err := dyn.Resource(userOperationGVR).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return false, err + } + last = obj + phase, found, _ := unstructured.NestedString(obj.Object, "status", "phase") + if !found || phase == "" { + return false, nil + } + return true, nil + }) + if err != nil { + return last, err + } + return last, nil +} diff --git a/internal/useroperation/cmd/unlock.go b/internal/useroperation/cmd/unlock.go new file mode 100644 index 00000000..d0dd69bc --- /dev/null +++ b/internal/useroperation/cmd/unlock.go @@ -0,0 +1,73 @@ +package useroperation + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func newUnlockCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "unlock ", + Short: "Unlock local user in Dex", + Args: cobra.ExactArgs(1), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + username := args[0] + wf, err := getWaitFlags(cmd) + if err != nil { + return err + } + + dyn, err := newDynamicClient(cmd) + if err != nil { + return err + } + + name := fmt.Sprintf("op-unlock-%d", time.Now().Unix()) + obj := &unstructured.Unstructured{ + Object: map[string]any{ + "apiVersion": "deckhouse.io/v1", + "kind": "UserOperation", + "metadata": map[string]any{ + "name": name, + }, + "spec": map[string]any{ + "user": username, + "type": "Unlock", + "initiatorType": "admin", + }, + }, + } + + _, err = createUserOperation(cmd.Context(), dyn, obj) + if err != nil { + return fmt.Errorf("create UserOperation: %w", err) + } + + if !wf.wait { + cmd.Printf("%s\n", name) + return nil + } + + result, err := waitUserOperation(cmd.Context(), dyn, name, wf.timeout) + if err != nil { + return fmt.Errorf("wait UserOperation: %w", err) + } + + phase, _, _ := unstructured.NestedString(result.Object, "status", "phase") + message, _, _ := unstructured.NestedString(result.Object, "status", "message") + if phase == "Failed" { + return fmt.Errorf("Unlock failed: %s", message) + } + cmd.Printf("Succeeded: %s\n", name) + return nil + }, + } + + addWaitFlags(cmd, waitFlags{wait: true, timeout: 5 * time.Minute}) + return cmd +} diff --git a/internal/useroperation/cmd/useroperation.go b/internal/useroperation/cmd/useroperation.go new file mode 100644 index 00000000..2073e7d8 --- /dev/null +++ b/internal/useroperation/cmd/useroperation.go @@ -0,0 +1,38 @@ +package useroperation + +import ( + "github.com/spf13/cobra" + "k8s.io/kubectl/pkg/util/templates" + + "github.com/deckhouse/deckhouse-cli/internal/system/flags" +) + +var userOperationLong = templates.LongDesc(` +Request local user operations (ResetPassword/Reset2FA/Lock/Unlock) in the Deckhouse user-authn module. + +The command creates a UserOperation custom resource and (optionally) waits for completion. + +© Flant JSC 2026`) + +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "user-operation", + Aliases: []string{"userop", "uo"}, + Short: "Request local user operations in user-authn module", + Long: userOperationLong, + SilenceErrors: true, + SilenceUsage: true, + } + + // Reuse standard kubeconfig/context flags (same as `d8 system ...`). + flags.AddPersistentFlags(cmd) + + cmd.AddCommand( + newReset2FACommand(), + newResetPasswordCommand(), + newLockCommand(), + newUnlockCommand(), + ) + + return cmd +}