diff --git a/README.md b/README.md index 0d2bb24c..eb046600 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 management (user-authn) + +Manage Dex static users via `UserOperation` custom resources. + +```bash +# Reset user's 2FA (TOTP) +d8 user reset2fa test-user --timeout 5m + +# Lock user for 10 minutes +d8 user lock test-user 10m --timeout 5m + +# Unlock user +d8 user 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 reset-password test-user "$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..825c8f74 --- /dev/null +++ b/internal/useroperation/cmd/lock.go @@ -0,0 +1,83 @@ +package useroperation + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func newLockCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "lock ", + Short: "Lock local user in Dex for a period of time", + Args: cobra.ExactArgs(2), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + username := args[0] + lockDuration := args[1] + // Validate duration format (must be parseable by time.ParseDuration; supports s/m/h). + if _, err := time.ParseDuration(lockDuration); err != nil { + return fmt.Errorf("invalid lockDuration %q: %w", lockDuration, 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": lockDuration, + }, + }, + }, + } + + _, 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.Long = "Lock local user in Dex for a period of time.\n\nThe lockDuration argument must be a duration string (e.g. 30s, 10m, 1h)." + addWaitFlags(cmd) + return cmd +} diff --git a/internal/useroperation/cmd/reset2fa.go b/internal/useroperation/cmd/reset2fa.go new file mode 100644 index 00000000..e4532748 --- /dev/null +++ b/internal/useroperation/cmd/reset2fa.go @@ -0,0 +1,74 @@ +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 + }, + } + + cmd.Long = "Reset local user's 2FA (TOTP) in Dex.\n\nThis requests a UserOperation of type Reset2FA and waits for completion by default." + addWaitFlags(cmd) + return cmd +} diff --git a/internal/useroperation/cmd/reset_password.go b/internal/useroperation/cmd/reset_password.go new file mode 100644 index 00000000..0932ac2c --- /dev/null +++ b/internal/useroperation/cmd/reset_password.go @@ -0,0 +1,79 @@ +package useroperation + +import ( + "fmt" + "time" + + "github.com/spf13/cobra" + "k8s.io/apimachinery/pkg/apis/meta/v1/unstructured" +) + +func newResetPasswordCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "reset-password ", + Aliases: []string{"resetpass"}, + Short: "Reset local user's password in Dex (requires bcrypt hash)", + Args: cobra.ExactArgs(2), + SilenceErrors: true, + SilenceUsage: true, + RunE: func(cmd *cobra.Command, args []string) error { + username := args[0] + bcryptHash := args[1] + 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.Long = "Reset local user's password in Dex.\n\nThe second argument must be a bcrypt hash (e.g. produced by `htpasswd -BinC 10`)." + addWaitFlags(cmd) + return cmd +} diff --git a/internal/useroperation/cmd/types.go b/internal/useroperation/cmd/types.go new file mode 100644 index 00000000..b7cdb7ee --- /dev/null +++ b/internal/useroperation/cmd/types.go @@ -0,0 +1,118 @@ +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", +} + +const ( + defaultWait = true + defaultTimeout = 5 * time.Minute +) + +type waitFlags struct { + wait bool + timeout time.Duration +} + +func addWaitFlags(cmd *cobra.Command) { + cmd.Flags().Bool("wait", defaultWait, "Wait for UserOperation completion and print result.") + cmd.Flags().Duration("timeout", defaultTimeout, "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) { + // This command group reuses persistent kubeconfig/context flags. + // Depending on how the command is constructed, these flags may exist either on the command itself + // or on a parent command. We support both. + 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() + + 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 { + // Hard error: if we can't read the resource, we can't reliably wait for completion. + return false, err + } + + phase, found, _ := unstructured.NestedString(obj.Object, "status", "phase") + if !found || phase == "" { + // Not completed yet: keep polling until the controller fills status.phase. + return false, nil + } + + // Completed (Succeeded/Failed): stop polling. + return true, nil + }) + if err != nil { + return nil, err + } + + // Fetch the final object to return the latest status/message. + obj, err := dyn.Resource(userOperationGVR).Get(ctx, name, metav1.GetOptions{}) + if err != nil { + return nil, err + } + return obj, nil +} diff --git a/internal/useroperation/cmd/unlock.go b/internal/useroperation/cmd/unlock.go new file mode 100644 index 00000000..cecce4a7 --- /dev/null +++ b/internal/useroperation/cmd/unlock.go @@ -0,0 +1,74 @@ +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 + }, + } + + cmd.Long = "Unlock local user in Dex.\n\nThis requests a UserOperation of type Unlock and waits for completion by default." + addWaitFlags(cmd) + return cmd +} diff --git a/internal/useroperation/cmd/useroperation.go b/internal/useroperation/cmd/useroperation.go new file mode 100644 index 00000000..5b267ffe --- /dev/null +++ b/internal/useroperation/cmd/useroperation.go @@ -0,0 +1,39 @@ +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(` +Manage Deckhouse users (user-authn). + +This command provides admin operations for Dex local users via UserOperation custom resources: +ResetPassword, Reset2FA, Lock, Unlock. + +© Flant JSC 2026`) + +func NewCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "user", + Aliases: []string{"userop"}, + Short: "Manage Deckhouse users (user-authn)", + 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 +}