diff --git a/cmd/authctl/main.go b/cmd/authctl/main.go new file mode 100644 index 0000000000..5b8c41e051 --- /dev/null +++ b/cmd/authctl/main.go @@ -0,0 +1,65 @@ +// Package main implements Cobra commands for management operations on authd. +package main + +import ( + "fmt" + "os" + + "github.com/spf13/cobra" + "github.com/ubuntu/authd/cmd/authctl/user" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" +) + +var rootCmd = &cobra.Command{ + Use: "authctl", + Short: "CLI tool to interact with authd", + Long: "authctl is a command-line tool to interact with the authd service for user and group management.", + PersistentPreRun: func(cmd *cobra.Command, args []string) { + // The command was successfully parsed, so we don't want cobra to print usage information on error. + cmd.SilenceUsage = true + }, + CompletionOptions: cobra.CompletionOptions{ + HiddenDefaultCmd: true, + }, + // We handle errors ourselves + SilenceErrors: true, + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { return cmd.Usage() }, +} + +func init() { + // Disable command sorting by name. This makes cobra print the commands in the + // order they are added to the root command and adds the `help` and `completion` + // commands at the end. + cobra.EnableCommandSorting = false + + rootCmd.AddCommand(user.UserCmd) +} + +func main() { + if err := rootCmd.Execute(); err != nil { + s, ok := status.FromError(err) + if !ok { + // If the error is not a gRPC status, we print it as is. + fmt.Fprintln(os.Stderr, err.Error()) + os.Exit(1) + } + + // If the error is a gRPC status, we print the message and exit with the gRPC status code. + switch s.Code() { + case codes.PermissionDenied: + fmt.Fprintln(os.Stderr, "Permission denied:", s.Message()) + default: + fmt.Fprintln(os.Stderr, "Error:", s.Message()) + } + code := int(s.Code()) + if code < 0 || code > 255 { + // We cannot exit with a negative code or a code greater than 255, + // so we map it to 1 in that case. + code = 1 + } + + os.Exit(code) + } +} diff --git a/cmd/authctl/main_test.go b/cmd/authctl/main_test.go new file mode 100644 index 0000000000..644495a2c1 --- /dev/null +++ b/cmd/authctl/main_test.go @@ -0,0 +1,68 @@ +package main_test + +import ( + "fmt" + "os" + "os/exec" + "testing" + + "github.com/ubuntu/authd/internal/testutils" + "github.com/ubuntu/authd/internal/testutils/golden" +) + +var authctlPath string + +func TestRootCommand(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + args []string + expectedExitCode int + }{ + "Usage_message_when_no_args": {expectedExitCode: 0}, + "Help_command": {args: []string{"help"}, expectedExitCode: 0}, + "Help_flag": {args: []string{"--help"}, expectedExitCode: 0}, + "Completion_command": {args: []string{"completion"}, expectedExitCode: 0}, + + "Error_on_invalid_command": {args: []string{"invalid-command"}, expectedExitCode: 1}, + "Error_on_invalid_flag": {args: []string{"--invalid-flag"}, expectedExitCode: 1}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + //nolint:gosec // G204 it's safe to use exec.Command with a variable here + cmd := exec.Command(authctlPath, tc.args...) + t.Logf("Running command: %s", cmd.String()) + outputBytes, err := cmd.CombinedOutput() + output := string(outputBytes) + exitCode := cmd.ProcessState.ExitCode() + + if tc.expectedExitCode == 0 && err != nil { + t.Logf("Command output:\n%s", output) + t.Errorf("Expected no error, but got: %v", err) + } + + if exitCode != tc.expectedExitCode { + t.Logf("Command output:\n%s", output) + t.Errorf("Expected exit code %d, got %d", tc.expectedExitCode, exitCode) + } + + golden.CheckOrUpdate(t, output) + }) + } +} + +func TestMain(m *testing.M) { + var cleanup func() + var err error + authctlPath, cleanup, err = testutils.BuildAuthctl() + if err != nil { + fmt.Fprintf(os.Stderr, "Setup: %v\n", err) + os.Exit(1) + } + defer cleanup() + + m.Run() +} diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Completion_command b/cmd/authctl/testdata/golden/TestRootCommand/Completion_command new file mode 100644 index 0000000000..fb3a14a225 --- /dev/null +++ b/cmd/authctl/testdata/golden/TestRootCommand/Completion_command @@ -0,0 +1,16 @@ +Generate the autocompletion script for authctl for the specified shell. +See each sub-command's help for details on how to use the generated script. + +Usage: + authctl completion [command] + +Available Commands: + bash Generate the autocompletion script for bash + zsh Generate the autocompletion script for zsh + fish Generate the autocompletion script for fish + powershell Generate the autocompletion script for powershell + +Flags: + -h, --help help for completion + +Use "authctl completion [command] --help" for more information about a command. diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_command b/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_command new file mode 100644 index 0000000000..31b2b174d7 --- /dev/null +++ b/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_command @@ -0,0 +1,14 @@ +Usage: + authctl [flags] + authctl [command] + +Available Commands: + user Commands related to users + help Help about any command + +Flags: + -h, --help help for authctl + +Use "authctl [command] --help" for more information about a command. + +unknown command "invalid-command" for "authctl" diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_flag b/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_flag new file mode 100644 index 0000000000..f900c50639 --- /dev/null +++ b/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_flag @@ -0,0 +1,14 @@ +Usage: + authctl [flags] + authctl [command] + +Available Commands: + user Commands related to users + help Help about any command + +Flags: + -h, --help help for authctl + +Use "authctl [command] --help" for more information about a command. + +unknown flag: --invalid-flag diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Help_command b/cmd/authctl/testdata/golden/TestRootCommand/Help_command new file mode 100644 index 0000000000..111485b844 --- /dev/null +++ b/cmd/authctl/testdata/golden/TestRootCommand/Help_command @@ -0,0 +1,14 @@ +authctl is a command-line tool to interact with the authd service for user and group management. + +Usage: + authctl [flags] + authctl [command] + +Available Commands: + user Commands related to users + help Help about any command + +Flags: + -h, --help help for authctl + +Use "authctl [command] --help" for more information about a command. diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Help_flag b/cmd/authctl/testdata/golden/TestRootCommand/Help_flag new file mode 100644 index 0000000000..111485b844 --- /dev/null +++ b/cmd/authctl/testdata/golden/TestRootCommand/Help_flag @@ -0,0 +1,14 @@ +authctl is a command-line tool to interact with the authd service for user and group management. + +Usage: + authctl [flags] + authctl [command] + +Available Commands: + user Commands related to users + help Help about any command + +Flags: + -h, --help help for authctl + +Use "authctl [command] --help" for more information about a command. diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Usage_message_when_no_args b/cmd/authctl/testdata/golden/TestRootCommand/Usage_message_when_no_args new file mode 100644 index 0000000000..46485fdb0b --- /dev/null +++ b/cmd/authctl/testdata/golden/TestRootCommand/Usage_message_when_no_args @@ -0,0 +1,12 @@ +Usage: + authctl [flags] + authctl [command] + +Available Commands: + user Commands related to users + help Help about any command + +Flags: + -h, --help help for authctl + +Use "authctl [command] --help" for more information about a command. diff --git a/cmd/authctl/user/lock.go b/cmd/authctl/user/lock.go new file mode 100644 index 0000000000..6f83b4e8b2 --- /dev/null +++ b/cmd/authctl/user/lock.go @@ -0,0 +1,28 @@ +package user + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/ubuntu/authd/internal/proto/authd" +) + +// lockCmd is a command to lock (disable) a user. +var lockCmd = &cobra.Command{ + Use: "lock ", + Short: "Lock (disable) a user managed by authd", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := NewUserServiceClient() + if err != nil { + return err + } + + _, err = client.LockUser(context.Background(), &authd.LockUserRequest{Name: args[0]}) + if err != nil { + return err + } + + return nil + }, +} diff --git a/cmd/authctl/user/testdata/db/one_user_and_group.db.yaml b/cmd/authctl/user/testdata/db/one_user_and_group.db.yaml new file mode 100644 index 0000000000..77567897ae --- /dev/null +++ b/cmd/authctl/user/testdata/db/one_user_and_group.db.yaml @@ -0,0 +1,17 @@ +users: + - name: user1 + uid: 1111 + gid: 11111 + gecos: |- + User1 gecos + On multiple lines + dir: /home/user1 + shell: /bin/bash + broker_id: broker-id +groups: + - name: group1 + gid: 11111 + ugid: "12345678" +users_to_groups: + - uid: 1111 + gid: 11111 diff --git a/cmd/authctl/user/testdata/empty.group b/cmd/authctl/user/testdata/empty.group new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_command b/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_command new file mode 100644 index 0000000000..ce84afc2e3 --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_command @@ -0,0 +1,14 @@ +Usage: + authctl user [flags] + authctl user [command] + +Available Commands: + lock Lock (disable) a user managed by authd + unlock Unlock (enable) a user managed by authd + +Flags: + -h, --help help for user + +Use "authctl user [command] --help" for more information about a command. + +unknown command "invalid-command" for "authctl user" diff --git a/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_flag b/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_flag new file mode 100644 index 0000000000..d3b1824aea --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_flag @@ -0,0 +1,14 @@ +Usage: + authctl user [flags] + authctl user [command] + +Available Commands: + lock Lock (disable) a user managed by authd + unlock Unlock (enable) a user managed by authd + +Flags: + -h, --help help for user + +Use "authctl user [command] --help" for more information about a command. + +unknown flag: --invalid-flag diff --git a/cmd/authctl/user/testdata/golden/TestUserCommand/Help_flag b/cmd/authctl/user/testdata/golden/TestUserCommand/Help_flag new file mode 100644 index 0000000000..e56b67c1da --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestUserCommand/Help_flag @@ -0,0 +1,14 @@ +Commands related to users + +Usage: + authctl user [flags] + authctl user [command] + +Available Commands: + lock Lock (disable) a user managed by authd + unlock Unlock (enable) a user managed by authd + +Flags: + -h, --help help for user + +Use "authctl user [command] --help" for more information about a command. diff --git a/cmd/authctl/user/testdata/golden/TestUserCommand/Usage_message_when_no_args b/cmd/authctl/user/testdata/golden/TestUserCommand/Usage_message_when_no_args new file mode 100644 index 0000000000..76259d0f98 --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestUserCommand/Usage_message_when_no_args @@ -0,0 +1,12 @@ +Usage: + authctl user [flags] + authctl user [command] + +Available Commands: + lock Lock (disable) a user managed by authd + unlock Unlock (enable) a user managed by authd + +Flags: + -h, --help help for user + +Use "authctl user [command] --help" for more information about a command. diff --git a/cmd/authctl/user/testdata/golden/TestUserLockCommand/Error_locking_invalid_user b/cmd/authctl/user/testdata/golden/TestUserLockCommand/Error_locking_invalid_user new file mode 100644 index 0000000000..93dd7dd5ff --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestUserLockCommand/Error_locking_invalid_user @@ -0,0 +1 @@ +Error: user "invaliduser" not found diff --git a/cmd/authctl/user/testdata/golden/TestUserLockCommand/Lock_user_success b/cmd/authctl/user/testdata/golden/TestUserLockCommand/Lock_user_success new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cmd/authctl/user/unlock.go b/cmd/authctl/user/unlock.go new file mode 100644 index 0000000000..4480eba584 --- /dev/null +++ b/cmd/authctl/user/unlock.go @@ -0,0 +1,28 @@ +package user + +import ( + "context" + + "github.com/spf13/cobra" + "github.com/ubuntu/authd/internal/proto/authd" +) + +// unlockCmd is a command to unlock (enable) a user. +var unlockCmd = &cobra.Command{ + Use: "unlock ", + Short: "Unlock (enable) a user managed by authd", + Args: cobra.ExactArgs(1), + RunE: func(cmd *cobra.Command, args []string) error { + client, err := NewUserServiceClient() + if err != nil { + return err + } + + _, err = client.UnlockUser(context.Background(), &authd.UnlockUserRequest{Name: args[0]}) + if err != nil { + return err + } + + return nil + }, +} diff --git a/cmd/authctl/user/user.go b/cmd/authctl/user/user.go new file mode 100644 index 0000000000..6a307199e2 --- /dev/null +++ b/cmd/authctl/user/user.go @@ -0,0 +1,49 @@ +// Package user provides utilities for managing user operations. +package user + +import ( + "fmt" + "os" + "regexp" + + "github.com/spf13/cobra" + "github.com/ubuntu/authd/internal/consts" + "github.com/ubuntu/authd/internal/proto/authd" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// UserCmd is a command to perform user-related operations. +var UserCmd = &cobra.Command{ + Use: "user", + Short: "Commands related to users", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { return cmd.Usage() }, +} + +// NewUserServiceClient creates and returns a new [authd.UserServiceClient]. +func NewUserServiceClient() (authd.UserServiceClient, error) { + authdSocket := os.Getenv("AUTHD_SOCKET") + if authdSocket == "" { + authdSocket = "unix://" + consts.DefaultSocketPath + } + + // Check if the socket has a scheme, else default to "unix://" + schemeRegex := regexp.MustCompile(`^[a-zA-Z][a-zA-Z0-9+.-]*:`) + if !schemeRegex.MatchString(authdSocket) { + authdSocket = "unix://" + authdSocket + } + + conn, err := grpc.NewClient(authdSocket, grpc.WithTransportCredentials(insecure.NewCredentials())) + if err != nil { + return nil, fmt.Errorf("failed to connect to authd: %w", err) + } + + client := authd.NewUserServiceClient(conn) + return client, nil +} + +func init() { + UserCmd.AddCommand(lockCmd) + UserCmd.AddCommand(unlockCmd) +} diff --git a/cmd/authctl/user/user_test.go b/cmd/authctl/user/user_test.go new file mode 100644 index 0000000000..9fc181515c --- /dev/null +++ b/cmd/authctl/user/user_test.go @@ -0,0 +1,123 @@ +package user_test + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + + "github.com/stretchr/testify/require" + "github.com/ubuntu/authd/internal/testutils" + "github.com/ubuntu/authd/internal/testutils/golden" + "google.golang.org/grpc/codes" +) + +var authctlPath string +var daemonPath string + +func TestUserCommand(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + args []string + expectedExitCode int + }{ + "Usage_message_when_no_args": {expectedExitCode: 0}, + "Help_flag": {args: []string{"--help"}, expectedExitCode: 0}, + + "Error_on_invalid_command": {args: []string{"invalid-command"}, expectedExitCode: 1}, + "Error_on_invalid_flag": {args: []string{"--invalid-flag"}, expectedExitCode: 1}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + //nolint:gosec // G204 it's safe to use exec.Command with a variable here + cmd := exec.Command(authctlPath, append([]string{"user"}, tc.args...)...) + t.Logf("Running command: %s", strings.Join(cmd.Args, " ")) + outputBytes, err := cmd.CombinedOutput() + output := string(outputBytes) + exitCode := cmd.ProcessState.ExitCode() + + if tc.expectedExitCode == 0 && err != nil { + t.Logf("Command output:\n%s", output) + t.Errorf("Expected no error, but got: %v", err) + } + + if exitCode != tc.expectedExitCode { + t.Logf("Command output:\n%s", output) + t.Errorf("Expected exit code %d, got %d", tc.expectedExitCode, exitCode) + } + + golden.CheckOrUpdate(t, output) + }) + } +} + +func TestUserLockCommand(t *testing.T) { + t.Parallel() + + daemonSocket := testutils.StartDaemon(t, daemonPath, + testutils.WithGroupFile(filepath.Join("testdata", "empty.group")), + testutils.WithPreviousDBState("one_user_and_group"), + testutils.WithCurrentUserAsRoot, + ) + + err := os.Setenv("AUTHD_SOCKET", daemonSocket) + require.NoError(t, err, "Failed to set AUTHD_SOCKET environment variable") + + tests := map[string]struct { + args []string + expectedExitCode int + }{ + "Lock_user_success": {args: []string{"lock", "user1"}, expectedExitCode: 0}, + + "Error_locking_invalid_user": {args: []string{"lock", "invaliduser"}, expectedExitCode: int(codes.NotFound)}, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + //nolint:gosec // G204 it's safe to use exec.Command with a variable here + cmd := exec.Command(authctlPath, append([]string{"user"}, tc.args...)...) + t.Logf("Running command: %s", strings.Join(cmd.Args, " ")) + outputBytes, err := cmd.CombinedOutput() + output := string(outputBytes) + exitCode := cmd.ProcessState.ExitCode() + + t.Logf("Command output:\n%s", output) + + if tc.expectedExitCode == 0 { + require.NoError(t, err) + } + require.Equal(t, tc.expectedExitCode, exitCode, "Expected exit code does not match actual exit code") + + golden.CheckOrUpdate(t, output) + }) + } +} + +func TestMain(m *testing.M) { + var authctlCleanup func() + var err error + authctlPath, authctlCleanup, err = testutils.BuildAuthctl() + if err != nil { + fmt.Fprintf(os.Stderr, "Setup: %v\n", err) + os.Exit(1) + } + defer authctlCleanup() + + var daemonCleanup func() + daemonPath, daemonCleanup, err = testutils.BuildDaemonWithExampleBroker() + if err != nil { + fmt.Fprintf(os.Stderr, "Setup: %v\n", err) + os.Exit(1) + } + defer daemonCleanup() + + m.Run() +} diff --git a/cmd/authd/daemon/testdata/golden/TestMaybeMigrateBBoltToSQLite/Migration_if_bbolt_exists_and_sqlite_does_not_exist/db b/cmd/authd/daemon/testdata/golden/TestMaybeMigrateBBoltToSQLite/Migration_if_bbolt_exists_and_sqlite_does_not_exist/db index 9a59a4ead0..9c042d2317 100644 --- a/cmd/authd/daemon/testdata/golden/TestMaybeMigrateBBoltToSQLite/Migration_if_bbolt_exists_and_sqlite_does_not_exist/db +++ b/cmd/authd/daemon/testdata/golden/TestMaybeMigrateBBoltToSQLite/Migration_if_bbolt_exists_and_sqlite_does_not_exist/db @@ -61,4 +61,4 @@ users_to_groups: gid: 44444 - uid: 4444 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/debian/install b/debian/install index 094a6ed57d..b5b56345ae 100755 --- a/debian/install +++ b/debian/install @@ -3,6 +3,9 @@ # Install daemon usr/bin/authd ${env:AUTHD_DAEMONS_PATH} +# Install CLI tool +usr/bin/authctl /usr/bin/ + # Install authd config file debian/authd-config/authd.yaml /etc/authd/ @@ -18,3 +21,8 @@ ${env:BUILT_PAM_LIBS_PATH}/go-exec/pam_authd_exec.so ${env:AUTHD_PAM_MODULES_PAT # Install NSS library with right soname target/${DEB_HOST_RUST_TYPE}/release/libnss_authd.so => /usr/lib/${DEB_TARGET_GNU_TYPE}/libnss_authd.so.2 + +# Shell completion scripts +shell-completion/bash/authctl /usr/share/bash-completion/completions/ +shell-completion/zsh/_authctl /usr/share/zsh/vendor-completions/ +shell-completion/fish/authctl.fish /usr/share/fish/vendor_completions.d/ diff --git a/internal/proto/authd/authd.pb.go b/internal/proto/authd/authd.pb.go index cdce54cedb..2003788ed8 100644 --- a/internal/proto/authd/authd.pb.go +++ b/internal/proto/authd/authd.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: // protoc-gen-go v1.36.8 -// protoc v4.23.4 +// protoc v6.32.0 // source: authd.proto package authd @@ -993,6 +993,94 @@ func (x *GetUserByIDRequest) GetId() uint32 { return 0 } +type LockUserRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *LockUserRequest) Reset() { + *x = LockUserRequest{} + mi := &file_authd_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *LockUserRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*LockUserRequest) ProtoMessage() {} + +func (x *LockUserRequest) ProtoReflect() protoreflect.Message { + mi := &file_authd_proto_msgTypes[18] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use LockUserRequest.ProtoReflect.Descriptor instead. +func (*LockUserRequest) Descriptor() ([]byte, []int) { + return file_authd_proto_rawDescGZIP(), []int{18} +} + +func (x *LockUserRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +type UnlockUserRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *UnlockUserRequest) Reset() { + *x = UnlockUserRequest{} + mi := &file_authd_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *UnlockUserRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*UnlockUserRequest) ProtoMessage() {} + +func (x *UnlockUserRequest) ProtoReflect() protoreflect.Message { + mi := &file_authd_proto_msgTypes[19] + if x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use UnlockUserRequest.ProtoReflect.Descriptor instead. +func (*UnlockUserRequest) Descriptor() ([]byte, []int) { + return file_authd_proto_rawDescGZIP(), []int{19} +} + +func (x *UnlockUserRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + type GetGroupByNameRequest struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -1002,7 +1090,7 @@ type GetGroupByNameRequest struct { func (x *GetGroupByNameRequest) Reset() { *x = GetGroupByNameRequest{} - mi := &file_authd_proto_msgTypes[18] + mi := &file_authd_proto_msgTypes[20] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1014,7 +1102,7 @@ func (x *GetGroupByNameRequest) String() string { func (*GetGroupByNameRequest) ProtoMessage() {} func (x *GetGroupByNameRequest) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[18] + mi := &file_authd_proto_msgTypes[20] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1027,7 +1115,7 @@ func (x *GetGroupByNameRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetGroupByNameRequest.ProtoReflect.Descriptor instead. func (*GetGroupByNameRequest) Descriptor() ([]byte, []int) { - return file_authd_proto_rawDescGZIP(), []int{18} + return file_authd_proto_rawDescGZIP(), []int{20} } func (x *GetGroupByNameRequest) GetName() string { @@ -1046,7 +1134,7 @@ type GetGroupByIDRequest struct { func (x *GetGroupByIDRequest) Reset() { *x = GetGroupByIDRequest{} - mi := &file_authd_proto_msgTypes[19] + mi := &file_authd_proto_msgTypes[21] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1058,7 +1146,7 @@ func (x *GetGroupByIDRequest) String() string { func (*GetGroupByIDRequest) ProtoMessage() {} func (x *GetGroupByIDRequest) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[19] + mi := &file_authd_proto_msgTypes[21] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1071,7 +1159,7 @@ func (x *GetGroupByIDRequest) ProtoReflect() protoreflect.Message { // Deprecated: Use GetGroupByIDRequest.ProtoReflect.Descriptor instead. func (*GetGroupByIDRequest) Descriptor() ([]byte, []int) { - return file_authd_proto_rawDescGZIP(), []int{19} + return file_authd_proto_rawDescGZIP(), []int{21} } func (x *GetGroupByIDRequest) GetId() uint32 { @@ -1095,7 +1183,7 @@ type User struct { func (x *User) Reset() { *x = User{} - mi := &file_authd_proto_msgTypes[20] + mi := &file_authd_proto_msgTypes[22] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1107,7 +1195,7 @@ func (x *User) String() string { func (*User) ProtoMessage() {} func (x *User) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[20] + mi := &file_authd_proto_msgTypes[22] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1120,7 +1208,7 @@ func (x *User) ProtoReflect() protoreflect.Message { // Deprecated: Use User.ProtoReflect.Descriptor instead. func (*User) Descriptor() ([]byte, []int) { - return file_authd_proto_rawDescGZIP(), []int{20} + return file_authd_proto_rawDescGZIP(), []int{22} } func (x *User) GetName() string { @@ -1174,7 +1262,7 @@ type Users struct { func (x *Users) Reset() { *x = Users{} - mi := &file_authd_proto_msgTypes[21] + mi := &file_authd_proto_msgTypes[23] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1186,7 +1274,7 @@ func (x *Users) String() string { func (*Users) ProtoMessage() {} func (x *Users) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[21] + mi := &file_authd_proto_msgTypes[23] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1199,7 +1287,7 @@ func (x *Users) ProtoReflect() protoreflect.Message { // Deprecated: Use Users.ProtoReflect.Descriptor instead. func (*Users) Descriptor() ([]byte, []int) { - return file_authd_proto_rawDescGZIP(), []int{21} + return file_authd_proto_rawDescGZIP(), []int{23} } func (x *Users) GetUsers() []*User { @@ -1222,7 +1310,7 @@ type Group struct { func (x *Group) Reset() { *x = Group{} - mi := &file_authd_proto_msgTypes[22] + mi := &file_authd_proto_msgTypes[24] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1234,7 +1322,7 @@ func (x *Group) String() string { func (*Group) ProtoMessage() {} func (x *Group) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[22] + mi := &file_authd_proto_msgTypes[24] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1247,7 +1335,7 @@ func (x *Group) ProtoReflect() protoreflect.Message { // Deprecated: Use Group.ProtoReflect.Descriptor instead. func (*Group) Descriptor() ([]byte, []int) { - return file_authd_proto_rawDescGZIP(), []int{22} + return file_authd_proto_rawDescGZIP(), []int{24} } func (x *Group) GetName() string { @@ -1287,7 +1375,7 @@ type Groups struct { func (x *Groups) Reset() { *x = Groups{} - mi := &file_authd_proto_msgTypes[23] + mi := &file_authd_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1299,7 +1387,7 @@ func (x *Groups) String() string { func (*Groups) ProtoMessage() {} func (x *Groups) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[23] + mi := &file_authd_proto_msgTypes[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1312,7 +1400,7 @@ func (x *Groups) ProtoReflect() protoreflect.Message { // Deprecated: Use Groups.ProtoReflect.Descriptor instead. func (*Groups) Descriptor() ([]byte, []int) { - return file_authd_proto_rawDescGZIP(), []int{23} + return file_authd_proto_rawDescGZIP(), []int{25} } func (x *Groups) GetGroups() []*Group { @@ -1333,7 +1421,7 @@ type ABResponse_BrokerInfo struct { func (x *ABResponse_BrokerInfo) Reset() { *x = ABResponse_BrokerInfo{} - mi := &file_authd_proto_msgTypes[24] + mi := &file_authd_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1345,7 +1433,7 @@ func (x *ABResponse_BrokerInfo) String() string { func (*ABResponse_BrokerInfo) ProtoMessage() {} func (x *ABResponse_BrokerInfo) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[24] + mi := &file_authd_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1392,7 +1480,7 @@ type GAMResponse_AuthenticationMode struct { func (x *GAMResponse_AuthenticationMode) Reset() { *x = GAMResponse_AuthenticationMode{} - mi := &file_authd_proto_msgTypes[25] + mi := &file_authd_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1404,7 +1492,7 @@ func (x *GAMResponse_AuthenticationMode) String() string { func (*GAMResponse_AuthenticationMode) ProtoMessage() {} func (x *GAMResponse_AuthenticationMode) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[25] + mi := &file_authd_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1449,7 +1537,7 @@ type IARequest_AuthenticationData struct { func (x *IARequest_AuthenticationData) Reset() { *x = IARequest_AuthenticationData{} - mi := &file_authd_proto_msgTypes[26] + mi := &file_authd_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1461,7 +1549,7 @@ func (x *IARequest_AuthenticationData) String() string { func (*IARequest_AuthenticationData) ProtoMessage() {} func (x *IARequest_AuthenticationData) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[26] + mi := &file_authd_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1640,7 +1728,11 @@ const file_authd_proto_rawDesc = "" + "\x04name\x18\x01 \x01(\tR\x04name\x12&\n" + "\x0eshouldPreCheck\x18\x02 \x01(\bR\x0eshouldPreCheck\"$\n" + "\x12GetUserByIDRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\rR\x02id\"+\n" + + "\x02id\x18\x01 \x01(\rR\x02id\"%\n" + + "\x0fLockUserRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"'\n" + + "\x11UnlockUserRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\"+\n" + "\x15GetGroupByNameRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\"%\n" + "\x13GetGroupByIDRequest\x12\x0e\n" + @@ -1674,11 +1766,14 @@ const file_authd_proto_rawDesc = "" + "\x0fIsAuthenticated\x12\x10.authd.IARequest\x1a\x11.authd.IAResponse\x12,\n" + "\n" + "EndSession\x12\x10.authd.ESRequest\x1a\f.authd.Empty\x12<\n" + - "\x17SetDefaultBrokerForUser\x12\x13.authd.SDBFURequest\x1a\f.authd.Empty2\xcb\x02\n" + + "\x17SetDefaultBrokerForUser\x12\x13.authd.SDBFURequest\x1a\f.authd.Empty2\xb3\x03\n" + "\vUserService\x129\n" + "\rGetUserByName\x12\x1b.authd.GetUserByNameRequest\x1a\v.authd.User\x125\n" + "\vGetUserByID\x12\x19.authd.GetUserByIDRequest\x1a\v.authd.User\x12'\n" + - "\tListUsers\x12\f.authd.Empty\x1a\f.authd.Users\x12<\n" + + "\tListUsers\x12\f.authd.Empty\x1a\f.authd.Users\x120\n" + + "\bLockUser\x12\x16.authd.LockUserRequest\x1a\f.authd.Empty\x124\n" + + "\n" + + "UnlockUser\x12\x18.authd.UnlockUserRequest\x1a\f.authd.Empty\x12<\n" + "\x0eGetGroupByName\x12\x1c.authd.GetGroupByNameRequest\x1a\f.authd.Group\x128\n" + "\fGetGroupByID\x12\x1a.authd.GetGroupByIDRequest\x1a\f.authd.Group\x12)\n" + "\n" + @@ -1697,7 +1792,7 @@ func file_authd_proto_rawDescGZIP() []byte { } var file_authd_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_authd_proto_msgTypes = make([]protoimpl.MessageInfo, 27) +var file_authd_proto_msgTypes = make([]protoimpl.MessageInfo, 29) var file_authd_proto_goTypes = []any{ (SessionMode)(0), // 0: authd.SessionMode (*Empty)(nil), // 1: authd.Empty @@ -1718,25 +1813,27 @@ var file_authd_proto_goTypes = []any{ (*ESRequest)(nil), // 16: authd.ESRequest (*GetUserByNameRequest)(nil), // 17: authd.GetUserByNameRequest (*GetUserByIDRequest)(nil), // 18: authd.GetUserByIDRequest - (*GetGroupByNameRequest)(nil), // 19: authd.GetGroupByNameRequest - (*GetGroupByIDRequest)(nil), // 20: authd.GetGroupByIDRequest - (*User)(nil), // 21: authd.User - (*Users)(nil), // 22: authd.Users - (*Group)(nil), // 23: authd.Group - (*Groups)(nil), // 24: authd.Groups - (*ABResponse_BrokerInfo)(nil), // 25: authd.ABResponse.BrokerInfo - (*GAMResponse_AuthenticationMode)(nil), // 26: authd.GAMResponse.AuthenticationMode - (*IARequest_AuthenticationData)(nil), // 27: authd.IARequest.AuthenticationData + (*LockUserRequest)(nil), // 19: authd.LockUserRequest + (*UnlockUserRequest)(nil), // 20: authd.UnlockUserRequest + (*GetGroupByNameRequest)(nil), // 21: authd.GetGroupByNameRequest + (*GetGroupByIDRequest)(nil), // 22: authd.GetGroupByIDRequest + (*User)(nil), // 23: authd.User + (*Users)(nil), // 24: authd.Users + (*Group)(nil), // 25: authd.Group + (*Groups)(nil), // 26: authd.Groups + (*ABResponse_BrokerInfo)(nil), // 27: authd.ABResponse.BrokerInfo + (*GAMResponse_AuthenticationMode)(nil), // 28: authd.GAMResponse.AuthenticationMode + (*IARequest_AuthenticationData)(nil), // 29: authd.IARequest.AuthenticationData } var file_authd_proto_depIdxs = []int32{ - 25, // 0: authd.ABResponse.brokers_infos:type_name -> authd.ABResponse.BrokerInfo + 27, // 0: authd.ABResponse.brokers_infos:type_name -> authd.ABResponse.BrokerInfo 0, // 1: authd.SBRequest.mode:type_name -> authd.SessionMode 9, // 2: authd.GAMRequest.supported_ui_layouts:type_name -> authd.UILayout - 26, // 3: authd.GAMResponse.authentication_modes:type_name -> authd.GAMResponse.AuthenticationMode + 28, // 3: authd.GAMResponse.authentication_modes:type_name -> authd.GAMResponse.AuthenticationMode 9, // 4: authd.SAMResponse.ui_layout_info:type_name -> authd.UILayout - 27, // 5: authd.IARequest.authentication_data:type_name -> authd.IARequest.AuthenticationData - 21, // 6: authd.Users.users:type_name -> authd.User - 23, // 7: authd.Groups.groups:type_name -> authd.Group + 29, // 5: authd.IARequest.authentication_data:type_name -> authd.IARequest.AuthenticationData + 23, // 6: authd.Users.users:type_name -> authd.User + 25, // 7: authd.Groups.groups:type_name -> authd.Group 1, // 8: authd.PAM.AvailableBrokers:input_type -> authd.Empty 2, // 9: authd.PAM.GetPreviousBroker:input_type -> authd.GPBRequest 6, // 10: authd.PAM.SelectBroker:input_type -> authd.SBRequest @@ -1748,25 +1845,29 @@ var file_authd_proto_depIdxs = []int32{ 17, // 16: authd.UserService.GetUserByName:input_type -> authd.GetUserByNameRequest 18, // 17: authd.UserService.GetUserByID:input_type -> authd.GetUserByIDRequest 1, // 18: authd.UserService.ListUsers:input_type -> authd.Empty - 19, // 19: authd.UserService.GetGroupByName:input_type -> authd.GetGroupByNameRequest - 20, // 20: authd.UserService.GetGroupByID:input_type -> authd.GetGroupByIDRequest - 1, // 21: authd.UserService.ListGroups:input_type -> authd.Empty - 4, // 22: authd.PAM.AvailableBrokers:output_type -> authd.ABResponse - 3, // 23: authd.PAM.GetPreviousBroker:output_type -> authd.GPBResponse - 7, // 24: authd.PAM.SelectBroker:output_type -> authd.SBResponse - 10, // 25: authd.PAM.GetAuthenticationModes:output_type -> authd.GAMResponse - 12, // 26: authd.PAM.SelectAuthenticationMode:output_type -> authd.SAMResponse - 14, // 27: authd.PAM.IsAuthenticated:output_type -> authd.IAResponse - 1, // 28: authd.PAM.EndSession:output_type -> authd.Empty - 1, // 29: authd.PAM.SetDefaultBrokerForUser:output_type -> authd.Empty - 21, // 30: authd.UserService.GetUserByName:output_type -> authd.User - 21, // 31: authd.UserService.GetUserByID:output_type -> authd.User - 22, // 32: authd.UserService.ListUsers:output_type -> authd.Users - 23, // 33: authd.UserService.GetGroupByName:output_type -> authd.Group - 23, // 34: authd.UserService.GetGroupByID:output_type -> authd.Group - 24, // 35: authd.UserService.ListGroups:output_type -> authd.Groups - 22, // [22:36] is the sub-list for method output_type - 8, // [8:22] is the sub-list for method input_type + 19, // 19: authd.UserService.LockUser:input_type -> authd.LockUserRequest + 20, // 20: authd.UserService.UnlockUser:input_type -> authd.UnlockUserRequest + 21, // 21: authd.UserService.GetGroupByName:input_type -> authd.GetGroupByNameRequest + 22, // 22: authd.UserService.GetGroupByID:input_type -> authd.GetGroupByIDRequest + 1, // 23: authd.UserService.ListGroups:input_type -> authd.Empty + 4, // 24: authd.PAM.AvailableBrokers:output_type -> authd.ABResponse + 3, // 25: authd.PAM.GetPreviousBroker:output_type -> authd.GPBResponse + 7, // 26: authd.PAM.SelectBroker:output_type -> authd.SBResponse + 10, // 27: authd.PAM.GetAuthenticationModes:output_type -> authd.GAMResponse + 12, // 28: authd.PAM.SelectAuthenticationMode:output_type -> authd.SAMResponse + 14, // 29: authd.PAM.IsAuthenticated:output_type -> authd.IAResponse + 1, // 30: authd.PAM.EndSession:output_type -> authd.Empty + 1, // 31: authd.PAM.SetDefaultBrokerForUser:output_type -> authd.Empty + 23, // 32: authd.UserService.GetUserByName:output_type -> authd.User + 23, // 33: authd.UserService.GetUserByID:output_type -> authd.User + 24, // 34: authd.UserService.ListUsers:output_type -> authd.Users + 1, // 35: authd.UserService.LockUser:output_type -> authd.Empty + 1, // 36: authd.UserService.UnlockUser:output_type -> authd.Empty + 25, // 37: authd.UserService.GetGroupByName:output_type -> authd.Group + 25, // 38: authd.UserService.GetGroupByID:output_type -> authd.Group + 26, // 39: authd.UserService.ListGroups:output_type -> authd.Groups + 24, // [24:40] is the sub-list for method output_type + 8, // [8:24] is the sub-list for method input_type 8, // [8:8] is the sub-list for extension type_name 8, // [8:8] is the sub-list for extension extendee 0, // [0:8] is the sub-list for field type_name @@ -1778,8 +1879,8 @@ func file_authd_proto_init() { return } file_authd_proto_msgTypes[8].OneofWrappers = []any{} - file_authd_proto_msgTypes[24].OneofWrappers = []any{} - file_authd_proto_msgTypes[26].OneofWrappers = []any{ + file_authd_proto_msgTypes[26].OneofWrappers = []any{} + file_authd_proto_msgTypes[28].OneofWrappers = []any{ (*IARequest_AuthenticationData_Secret)(nil), (*IARequest_AuthenticationData_Wait)(nil), (*IARequest_AuthenticationData_Skip)(nil), @@ -1791,7 +1892,7 @@ func file_authd_proto_init() { GoPackagePath: reflect.TypeOf(x{}).PkgPath(), RawDescriptor: unsafe.Slice(unsafe.StringData(file_authd_proto_rawDesc), len(file_authd_proto_rawDesc)), NumEnums: 1, - NumMessages: 27, + NumMessages: 29, NumExtensions: 0, NumServices: 2, }, diff --git a/internal/proto/authd/authd.proto b/internal/proto/authd/authd.proto index 9ecf9264eb..eb3ae7d63c 100644 --- a/internal/proto/authd/authd.proto +++ b/internal/proto/authd/authd.proto @@ -133,6 +133,9 @@ service UserService { rpc GetUserByName(GetUserByNameRequest) returns (User); rpc GetUserByID(GetUserByIDRequest) returns (User); rpc ListUsers(Empty) returns (Users); + rpc LockUser(LockUserRequest) returns (Empty); + rpc UnlockUser(UnlockUserRequest) returns (Empty); + rpc GetGroupByName(GetGroupByNameRequest) returns (Group); rpc GetGroupByID(GetGroupByIDRequest) returns (Group); rpc ListGroups(Empty) returns (Groups); @@ -147,6 +150,14 @@ message GetUserByIDRequest{ uint32 id = 1; } +message LockUserRequest{ + string name = 1; +} + +message UnlockUserRequest{ + string name = 1; +} + message GetGroupByNameRequest{ string name = 1; } diff --git a/internal/proto/authd/authd_grpc.pb.go b/internal/proto/authd/authd_grpc.pb.go index 93f26da2c7..69e2a3d127 100644 --- a/internal/proto/authd/authd_grpc.pb.go +++ b/internal/proto/authd/authd_grpc.pb.go @@ -1,7 +1,7 @@ // Code generated by protoc-gen-go-grpc. DO NOT EDIT. // versions: // - protoc-gen-go-grpc v1.5.1 -// - protoc v4.23.4 +// - protoc v6.32.0 // source: authd.proto package authd @@ -390,6 +390,8 @@ const ( UserService_GetUserByName_FullMethodName = "/authd.UserService/GetUserByName" UserService_GetUserByID_FullMethodName = "/authd.UserService/GetUserByID" UserService_ListUsers_FullMethodName = "/authd.UserService/ListUsers" + UserService_LockUser_FullMethodName = "/authd.UserService/LockUser" + UserService_UnlockUser_FullMethodName = "/authd.UserService/UnlockUser" UserService_GetGroupByName_FullMethodName = "/authd.UserService/GetGroupByName" UserService_GetGroupByID_FullMethodName = "/authd.UserService/GetGroupByID" UserService_ListGroups_FullMethodName = "/authd.UserService/ListGroups" @@ -402,6 +404,8 @@ type UserServiceClient interface { GetUserByName(ctx context.Context, in *GetUserByNameRequest, opts ...grpc.CallOption) (*User, error) GetUserByID(ctx context.Context, in *GetUserByIDRequest, opts ...grpc.CallOption) (*User, error) ListUsers(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Users, error) + LockUser(ctx context.Context, in *LockUserRequest, opts ...grpc.CallOption) (*Empty, error) + UnlockUser(ctx context.Context, in *UnlockUserRequest, opts ...grpc.CallOption) (*Empty, error) GetGroupByName(ctx context.Context, in *GetGroupByNameRequest, opts ...grpc.CallOption) (*Group, error) GetGroupByID(ctx context.Context, in *GetGroupByIDRequest, opts ...grpc.CallOption) (*Group, error) ListGroups(ctx context.Context, in *Empty, opts ...grpc.CallOption) (*Groups, error) @@ -445,6 +449,26 @@ func (c *userServiceClient) ListUsers(ctx context.Context, in *Empty, opts ...gr return out, nil } +func (c *userServiceClient) LockUser(ctx context.Context, in *LockUserRequest, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Empty) + err := c.cc.Invoke(ctx, UserService_LockUser_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *userServiceClient) UnlockUser(ctx context.Context, in *UnlockUserRequest, opts ...grpc.CallOption) (*Empty, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(Empty) + err := c.cc.Invoke(ctx, UserService_UnlockUser_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + func (c *userServiceClient) GetGroupByName(ctx context.Context, in *GetGroupByNameRequest, opts ...grpc.CallOption) (*Group, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Group) @@ -482,6 +506,8 @@ type UserServiceServer interface { GetUserByName(context.Context, *GetUserByNameRequest) (*User, error) GetUserByID(context.Context, *GetUserByIDRequest) (*User, error) ListUsers(context.Context, *Empty) (*Users, error) + LockUser(context.Context, *LockUserRequest) (*Empty, error) + UnlockUser(context.Context, *UnlockUserRequest) (*Empty, error) GetGroupByName(context.Context, *GetGroupByNameRequest) (*Group, error) GetGroupByID(context.Context, *GetGroupByIDRequest) (*Group, error) ListGroups(context.Context, *Empty) (*Groups, error) @@ -504,6 +530,12 @@ func (UnimplementedUserServiceServer) GetUserByID(context.Context, *GetUserByIDR func (UnimplementedUserServiceServer) ListUsers(context.Context, *Empty) (*Users, error) { return nil, status.Errorf(codes.Unimplemented, "method ListUsers not implemented") } +func (UnimplementedUserServiceServer) LockUser(context.Context, *LockUserRequest) (*Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method LockUser not implemented") +} +func (UnimplementedUserServiceServer) UnlockUser(context.Context, *UnlockUserRequest) (*Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method UnlockUser not implemented") +} func (UnimplementedUserServiceServer) GetGroupByName(context.Context, *GetGroupByNameRequest) (*Group, error) { return nil, status.Errorf(codes.Unimplemented, "method GetGroupByName not implemented") } @@ -588,6 +620,42 @@ func _UserService_ListUsers_Handler(srv interface{}, ctx context.Context, dec fu return interceptor(ctx, in, info, handler) } +func _UserService_LockUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(LockUserRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserServiceServer).LockUser(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserService_LockUser_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserServiceServer).LockUser(ctx, req.(*LockUserRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _UserService_UnlockUser_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(UnlockUserRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserServiceServer).UnlockUser(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserService_UnlockUser_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserServiceServer).UnlockUser(ctx, req.(*UnlockUserRequest)) + } + return interceptor(ctx, in, info, handler) +} + func _UserService_GetGroupByName_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { in := new(GetGroupByNameRequest) if err := dec(in); err != nil { @@ -661,6 +729,14 @@ var UserService_ServiceDesc = grpc.ServiceDesc{ MethodName: "ListUsers", Handler: _UserService_ListUsers_Handler, }, + { + MethodName: "LockUser", + Handler: _UserService_LockUser_Handler, + }, + { + MethodName: "UnlockUser", + Handler: _UserService_UnlockUser_Handler, + }, { MethodName: "GetGroupByName", Handler: _UserService_GetGroupByName_Handler, diff --git a/internal/services/errmessages/redactor.go b/internal/services/errmessages/redactor.go index c0881458f1..3fa37dd059 100644 --- a/internal/services/errmessages/redactor.go +++ b/internal/services/errmessages/redactor.go @@ -52,6 +52,9 @@ func FormatErrorMessage(ctx context.Context, method string, req, reply any, cc * // likely means that IsAuthenticated got cancelled, so we need to keep the error intact case codes.Canceled: break + case codes.PermissionDenied: + // permission denied, just format it + err = fmt.Errorf("permission denied: %v", st.Message()) // grpc error, just format it default: err = fmt.Errorf("error %s from server: %v", st.Code(), st.Message()) diff --git a/internal/services/pam/pam.go b/internal/services/pam/pam.go index 83918c1ce4..86dcc33fbd 100644 --- a/internal/services/pam/pam.go +++ b/internal/services/pam/pam.go @@ -125,8 +125,6 @@ func (s Service) GetPreviousBroker(ctx context.Context, req *authd.GPBRequest) ( // SelectBroker starts a new session and selects the requested broker for the user. func (s Service) SelectBroker(ctx context.Context, req *authd.SBRequest) (resp *authd.SBResponse, err error) { - defer decorate.OnError(&err, "can't start authentication transaction") - username := req.GetUsername() brokerID := req.GetBrokerId() lang := req.GetLang() @@ -250,8 +248,6 @@ func (s Service) SelectAuthenticationMode(ctx context.Context, req *authd.SAMReq // IsAuthenticated returns broker answer to authentication request. func (s Service) IsAuthenticated(ctx context.Context, req *authd.IARequest) (resp *authd.IAResponse, err error) { - defer decorate.OnError(&err, "can't check authentication") - sessionID := req.GetSessionId() if sessionID == "" { log.Errorf(ctx, "IsAuthenticated: No session ID provided") @@ -291,6 +287,24 @@ func (s Service) IsAuthenticated(ctx context.Context, req *authd.IARequest) (res return nil, fmt.Errorf("user data from broker invalid: %v", err) } + // authd uses lowercase usernames + uInfo.Name = strings.ToLower(uInfo.Name) + + // Check if the user is locked. We can only do this after the broker has granted access, because we want to avoid + // leaking whether a user exists or not to unauthenticated users. + // TODO: We might want to let the broker know whether the user is locked or not, so that it can avoid storing any + // updated tokens or user info on disk. + userIsLocked, err := s.userManager.IsUserLocked(uInfo.Name) + if err != nil && !errors.Is(err, users.NoDataFoundError{}) { + log.Errorf(ctx, "IsAuthenticated: Could not check if user %q is locked: %v", uInfo.Name, err) + return nil, fmt.Errorf("could not check if user %q is locked: %w", uInfo.Name, err) + } + // Throw an error if the user trying to authenticate already exists in the database and is locked + if err == nil && userIsLocked { + log.Noticef(ctx, "Authentication failure: user %q is locked", uInfo.Name) + return nil, status.Error(codes.PermissionDenied, fmt.Sprintf("user %s is locked", uInfo.Name)) + } + // Update database and local groups on granted auth. if err := s.userManager.UpdateUser(uInfo); err != nil { log.Errorf(ctx, "IsAuthenticated: Could not update user %q in database: %v", uInfo.Name, err) diff --git a/internal/services/pam/pam_test.go b/internal/services/pam/pam_test.go index d773173573..5fdb1b163d 100644 --- a/internal/services/pam/pam_test.go +++ b/internal/services/pam/pam_test.go @@ -193,6 +193,7 @@ func TestSelectBroker(t *testing.T) { brokerID string username string sessionMode string + existingDB string currentUserNotRoot bool @@ -214,8 +215,18 @@ func TestSelectBroker(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() + cacheDir := t.TempDir() + if tc.existingDB != "" { + err := db.Z_ForTests_CreateDBFromYAML(filepath.Join(testutils.TestFamilyPath(t), tc.existingDB), cacheDir) + require.NoError(t, err, "Setup: could not create database from testdata") + } + + m, err := users.NewManager(users.DefaultConfig, cacheDir) + require.NoError(t, err, "Setup: could not create user manager") + t.Cleanup(func() { _ = m.Stop() }) + pm := newPermissionManager(t, tc.currentUserNotRoot) - client := newPamClient(t, nil, globalBrokerManager, &pm) + client := newPamClient(t, m, globalBrokerManager, &pm) switch tc.brokerID { case "": @@ -434,6 +445,7 @@ func TestIsAuthenticated(t *testing.T) { "Error_when_not_root": {username: "success", currentUserNotRoot: true}, "Error_when_sessionID_is_empty": {sessionID: "-"}, "Error_when_there_is_no_broker": {sessionID: "invalid-session"}, + "Error_when_user_is_locked": {username: "locked", existingDB: "cache-with-locked-user.db"}, // broker errors "Error_when_authenticating": {username: "ia_error"}, diff --git a/internal/services/pam/testdata/TestIsAuthenticated/cache-with-locked-user.db b/internal/services/pam/testdata/TestIsAuthenticated/cache-with-locked-user.db new file mode 100644 index 0000000000..cf3be9e829 --- /dev/null +++ b/internal/services/pam/testdata/TestIsAuthenticated/cache-with-locked-user.db @@ -0,0 +1,16 @@ +users: + - name: testisauthenticated/error_when_user_is_locked_separator_locked + uid: 1111 + gid: 11111 + gecos: gecos for other user + dir: /home/locked + shell: /bin/bash + broker_id: broker-id + locked: true +groups: + - name: group1 + gid: 11111 + ugid: ugid +users_to_groups: + - uid: 1111 + gid: 11111 diff --git a/internal/services/pam/testdata/golden/TestIDGeneration/Generate_ID/cache.db b/internal/services/pam/testdata/golden/TestIDGeneration/Generate_ID/cache.db index 375d7c41a3..ac55ff4e4b 100644 --- a/internal/services/pam/testdata/golden/TestIDGeneration/Generate_ID/cache.db +++ b/internal/services/pam/testdata/golden/TestIDGeneration/Generate_ID/cache.db @@ -17,4 +17,4 @@ users_to_groups: gid: 1111 - uid: 1111 gid: 22222 -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Denies_authentication_when_broker_times_out/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Denies_authentication_when_broker_times_out/cache.db index 8bd98be48c..03de3ff950 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Denies_authentication_when_broker_times_out/cache.db +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Denies_authentication_when_broker_times_out/cache.db @@ -1,4 +1,4 @@ users: [] groups: [] users_to_groups: [] -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_empty_data_even_if_granted/IsAuthenticated b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_empty_data_even_if_granted/IsAuthenticated index 91c579daab..9077010ef2 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_empty_data_even_if_granted/IsAuthenticated +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_empty_data_even_if_granted/IsAuthenticated @@ -1,4 +1,4 @@ FIRST CALL: access: msg: - err: can't check authentication: missing key "userinfo" in returned message, got: {} + err: missing key "userinfo" in returned message, got: {} diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_empty_data_even_if_granted/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_empty_data_even_if_granted/cache.db index 8bd98be48c..03de3ff950 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_empty_data_even_if_granted/cache.db +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_empty_data_even_if_granted/cache.db @@ -1,4 +1,4 @@ users: [] groups: [] users_to_groups: [] -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_updating_local_groups_with_unexisting_file/IsAuthenticated b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_updating_local_groups_with_unexisting_file/IsAuthenticated index 77877bd5de..a30ec99d72 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_updating_local_groups_with_unexisting_file/IsAuthenticated +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_updating_local_groups_with_unexisting_file/IsAuthenticated @@ -1,4 +1,4 @@ FIRST CALL: access: msg: - err: can't check authentication: failed to update user "testisauthenticated/error_on_updating_local_groups_with_unexisting_file_separator_success_with_local_groups": could not update local groups for user "testisauthenticated/error_on_updating_local_groups_with_unexisting_file_separator_success_with_local_groups": could not fetch existing local group: open : no such file or directory + err: failed to update user "testisauthenticated/error_on_updating_local_groups_with_unexisting_file_separator_success_with_local_groups": could not update local groups for user "testisauthenticated/error_on_updating_local_groups_with_unexisting_file_separator_success_with_local_groups": could not fetch existing local group: open : no such file or directory diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_updating_local_groups_with_unexisting_file/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_updating_local_groups_with_unexisting_file/cache.db index 963c89e45e..16b6008850 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_updating_local_groups_with_unexisting_file/cache.db +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_on_updating_local_groups_with_unexisting_file/cache.db @@ -17,4 +17,4 @@ users_to_groups: gid: 1111 - uid: 1111 gid: 22222 -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_authenticating/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_authenticating/cache.db index 8bd98be48c..03de3ff950 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_authenticating/cache.db +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_authenticating/cache.db @@ -1,4 +1,4 @@ users: [] groups: [] users_to_groups: [] -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_access/IsAuthenticated b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_access/IsAuthenticated index 49f1b2961e..92f4beafcd 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_access/IsAuthenticated +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_access/IsAuthenticated @@ -1,4 +1,4 @@ FIRST CALL: access: msg: - err: can't check authentication: invalid access authentication key: invalid + err: invalid access authentication key: invalid diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_access/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_access/cache.db index 8bd98be48c..03de3ff950 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_access/cache.db +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_access/cache.db @@ -1,4 +1,4 @@ users: [] groups: [] users_to_groups: [] -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_data/IsAuthenticated b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_data/IsAuthenticated index 2a70683dd0..bd72dfbe47 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_data/IsAuthenticated +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_data/IsAuthenticated @@ -1,5 +1,5 @@ FIRST CALL: access: msg: - err: can't check authentication: response returned by the broker is not a valid json: invalid character 'i' looking for beginning of value + err: response returned by the broker is not a valid json: invalid character 'i' looking for beginning of value Broker returned: invalid diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_data/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_data/cache.db index 8bd98be48c..03de3ff950 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_data/cache.db +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_data/cache.db @@ -1,4 +1,4 @@ users: [] groups: [] users_to_groups: [] -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_userinfo/IsAuthenticated b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_userinfo/IsAuthenticated index b917b0921c..b071345de3 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_userinfo/IsAuthenticated +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_userinfo/IsAuthenticated @@ -1,4 +1,4 @@ FIRST CALL: access: msg: - err: can't check authentication: message is not JSON formatted: json: cannot unmarshal string into Go value of type types.UserInfo + err: message is not JSON formatted: json: cannot unmarshal string into Go value of type types.UserInfo diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_userinfo/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_userinfo/cache.db index 8bd98be48c..03de3ff950 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_userinfo/cache.db +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_broker_returns_invalid_userinfo/cache.db @@ -1,4 +1,4 @@ users: [] groups: [] users_to_groups: [] -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_calling_second_time_without_cancelling/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_calling_second_time_without_cancelling/cache.db index cef36229a8..79940af015 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_calling_second_time_without_cancelling/cache.db +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_calling_second_time_without_cancelling/cache.db @@ -17,4 +17,4 @@ users_to_groups: gid: 1111 - uid: 1111 gid: 22222 -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_not_root/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_not_root/cache.db index 8bd98be48c..03de3ff950 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_not_root/cache.db +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_not_root/cache.db @@ -1,4 +1,4 @@ users: [] groups: [] users_to_groups: [] -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_sessionID_is_empty/IsAuthenticated b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_sessionID_is_empty/IsAuthenticated index 6ec3830d89..96ea6d8d5e 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_sessionID_is_empty/IsAuthenticated +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_sessionID_is_empty/IsAuthenticated @@ -1,4 +1,4 @@ FIRST CALL: access: msg: - err: error InvalidArgument from server: can't check authentication: rpc error: code = InvalidArgument desc = no session ID provided + err: error InvalidArgument from server: no session ID provided diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_sessionID_is_empty/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_sessionID_is_empty/cache.db index 8bd98be48c..03de3ff950 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_sessionID_is_empty/cache.db +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_sessionID_is_empty/cache.db @@ -1,4 +1,4 @@ users: [] groups: [] users_to_groups: [] -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_there_is_no_broker/IsAuthenticated b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_there_is_no_broker/IsAuthenticated index 5137dc5e62..10d7fab744 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_there_is_no_broker/IsAuthenticated +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_there_is_no_broker/IsAuthenticated @@ -1,4 +1,4 @@ FIRST CALL: access: msg: - err: can't check authentication: no broker found for session "invalid-session" + err: no broker found for session "invalid-session" diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_there_is_no_broker/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_there_is_no_broker/cache.db index 8bd98be48c..03de3ff950 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_there_is_no_broker/cache.db +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_there_is_no_broker/cache.db @@ -1,4 +1,4 @@ users: [] groups: [] users_to_groups: [] -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_user_is_locked/IsAuthenticated b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_user_is_locked/IsAuthenticated new file mode 100644 index 0000000000..15055a4978 --- /dev/null +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_user_is_locked/IsAuthenticated @@ -0,0 +1,4 @@ +FIRST CALL: + access: + msg: + err: permission denied: user testisauthenticated/error_when_user_is_locked_separator_locked is locked diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_user_is_locked/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_user_is_locked/cache.db new file mode 100644 index 0000000000..f0770123eb --- /dev/null +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_user_is_locked/cache.db @@ -0,0 +1,17 @@ +users: + - name: testisauthenticated/error_when_user_is_locked_separator_locked + uid: 1111 + gid: 11111 + gecos: gecos for other user + dir: /home/locked + shell: /bin/bash + broker_id: broker-id + locked: true +groups: + - name: group1 + gid: 11111 + ugid: ugid +users_to_groups: + - uid: 1111 + gid: 11111 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate/cache.db index 86305c92c1..b4536c012f 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate/cache.db +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate/cache.db @@ -17,4 +17,4 @@ users_to_groups: gid: 1111 - uid: 1111 gid: 22222 -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_if_first_call_is_canceled/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_if_first_call_is_canceled/cache.db index 4b28b6b924..f87cf6d258 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_if_first_call_is_canceled/cache.db +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Successfully_authenticate_if_first_call_is_canceled/cache.db @@ -17,4 +17,4 @@ users_to_groups: gid: 1111 - uid: 1111 gid: 22222 -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Update_existing_DB_on_success/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Update_existing_DB_on_success/cache.db index 00ac6473e7..bf4add81e3 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Update_existing_DB_on_success/cache.db +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Update_existing_DB_on_success/cache.db @@ -26,4 +26,4 @@ users_to_groups: gid: 88888 - uid: 77777 gid: 88888 -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Update_local_groups/cache.db b/internal/services/pam/testdata/golden/TestIsAuthenticated/Update_local_groups/cache.db index 533c7c30fd..f91b0c5536 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Update_local_groups/cache.db +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Update_local_groups/cache.db @@ -17,4 +17,4 @@ users_to_groups: gid: 1111 - uid: 1111 gid: 22222 -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestSetDefaultBrokerForUser/Set_default_broker_for_existing_user_with_no_broker/cache.db b/internal/services/pam/testdata/golden/TestSetDefaultBrokerForUser/Set_default_broker_for_existing_user_with_no_broker/cache.db index 805aa6c6af..3bd5df74ef 100644 --- a/internal/services/pam/testdata/golden/TestSetDefaultBrokerForUser/Set_default_broker_for_existing_user_with_no_broker/cache.db +++ b/internal/services/pam/testdata/golden/TestSetDefaultBrokerForUser/Set_default_broker_for_existing_user_with_no_broker/cache.db @@ -73,4 +73,4 @@ users_to_groups: gid: 55555 - uid: 5555 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/internal/services/pam/testdata/golden/TestSetDefaultBrokerForUser/Update_default_broker_for_existing_user_with_a_broker/cache.db b/internal/services/pam/testdata/golden/TestSetDefaultBrokerForUser/Update_default_broker_for_existing_user_with_a_broker/cache.db index cf71a7de8c..30d8a3b0a4 100644 --- a/internal/services/pam/testdata/golden/TestSetDefaultBrokerForUser/Update_default_broker_for_existing_user_with_a_broker/cache.db +++ b/internal/services/pam/testdata/golden/TestSetDefaultBrokerForUser/Update_default_broker_for_existing_user_with_a_broker/cache.db @@ -72,4 +72,4 @@ users_to_groups: gid: 55555 - uid: 5555 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/internal/services/testdata/golden/TestRegisterGRPCServices b/internal/services/testdata/golden/TestRegisterGRPCServices index eb115926b9..ad43546e3c 100644 --- a/internal/services/testdata/golden/TestRegisterGRPCServices +++ b/internal/services/testdata/golden/TestRegisterGRPCServices @@ -45,6 +45,12 @@ authd.UserService: - name: ListUsers isclientstream: false isserverstream: false + - name: LockUser + isclientstream: false + isserverstream: false + - name: UnlockUser + isclientstream: false + isserverstream: false metadata: authd.proto grpc.health.v1.Health: methods: diff --git a/internal/services/user/testdata/locked-user.db.yaml b/internal/services/user/testdata/locked-user.db.yaml new file mode 100644 index 0000000000..e177d3bfb8 --- /dev/null +++ b/internal/services/user/testdata/locked-user.db.yaml @@ -0,0 +1,49 @@ +users: + - name: user1 + uid: 1111 + gid: 11111 + gecos: |- + User1 gecos + On multiple lines + dir: /home/user1 + shell: /bin/bash + broker_id: broker-id + locked: true + - name: user2 + uid: 2222 + gid: 22222 + gecos: User2 + dir: /home/user2 + shell: /bin/dash + broker_id: broker-id + - name: user3 + uid: 3333 + gid: 33333 + gecos: User3 + dir: /home/user3 + shell: /bin/zsh + broker_id: broker-id +groups: + - name: group1 + gid: 11111 + ugid: group1 + - name: group2 + gid: 22222 + ugid: group2 + - name: group3 + gid: 33333 + ugid: group3 + - name: commongroup + gid: 99999 + ugid: commongroup +users_to_groups: + - uid: 1111 + gid: 11111 + - uid: 2222 + gid: 22222 + - uid: 2222 + gid: 99999 + - uid: 3333 + gid: 33333 + - uid: 3333 + gid: 99999 diff --git a/internal/services/user/user.go b/internal/services/user/user.go index 0eb3ae9690..a67fa258d2 100644 --- a/internal/services/user/user.go +++ b/internal/services/user/user.go @@ -116,6 +116,40 @@ func (s Service) ListUsers(ctx context.Context, req *authd.Empty) (*authd.Users, return &res, nil } +// LockUser marks a user as locked. +func (s Service) LockUser(ctx context.Context, req *authd.LockUserRequest) (*authd.Empty, error) { + if err := s.permissionManager.CheckRequestIsFromRoot(ctx); err != nil { + return nil, err + } + + if req.GetName() == "" { + return nil, status.Error(codes.InvalidArgument, "no user name provided") + } + + if err := s.userManager.LockUser(req.GetName()); err != nil { + return nil, grpcError(err) + } + + return &authd.Empty{}, nil +} + +// UnlockUser marks a user as unlocked. +func (s Service) UnlockUser(ctx context.Context, req *authd.UnlockUserRequest) (*authd.Empty, error) { + if err := s.permissionManager.CheckRequestIsFromRoot(ctx); err != nil { + return nil, err + } + + if req.GetName() == "" { + return nil, status.Error(codes.InvalidArgument, "no user name provided") + } + + if err := s.userManager.UnlockUser(req.GetName()); err != nil { + return nil, grpcError(err) + } + + return &authd.Empty{}, nil +} + // GetGroupByName returns the group entry for the given group name. func (s Service) GetGroupByName(ctx context.Context, req *authd.GetGroupByNameRequest) (*authd.Group, error) { if req.GetName() == "" { diff --git a/internal/services/user/user_test.go b/internal/services/user/user_test.go index 720dfa1839..80ddad7ec6 100644 --- a/internal/services/user/user_test.go +++ b/internal/services/user/user_test.go @@ -212,6 +212,68 @@ func TestListGroups(t *testing.T) { } } +func TestLockUser(t *testing.T) { + tests := map[string]struct { + sourceDB string + + username string + currentUserNotRoot bool + + wantErr bool + }{ + "Successfully_lock_user": {username: "user1"}, + + "Error_when_username_is_empty": {wantErr: true}, + "Error_when_user_does_not_exist": {username: "doesnotexist", wantErr: true}, + "Error_when_not_root": {username: "notroot", currentUserNotRoot: true, wantErr: true}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + client := newUserServiceClient(t, tc.sourceDB) + + _, err := client.LockUser(context.Background(), &authd.LockUserRequest{Name: tc.username}) + if tc.wantErr { + require.Error(t, err, "LockUser should return an error, but did not") + return + } + require.NoError(t, err, "LockUser should not return an error, but did") + }) + } +} + +func TestUnlockUser(t *testing.T) { + tests := map[string]struct { + sourceDB string + + username string + currentUserNotRoot bool + + wantErr bool + }{ + "Successfully_unlock_user": {username: "user1"}, + + "Error_when_username_is_empty": {wantErr: true}, + "Error_when_user_does_not_exist": {username: "doesnotexist", wantErr: true}, + "Error_when_not_root": {username: "notroot", currentUserNotRoot: true, wantErr: true}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if tc.sourceDB == "" { + tc.sourceDB = "locked-user.db.yaml" + } + + client := newUserServiceClient(t, tc.sourceDB) + + _, err := client.UnlockUser(context.Background(), &authd.UnlockUserRequest{Name: tc.username}) + if tc.wantErr { + require.Error(t, err, "UnlockUser should return an error, but did not") + return + } + require.NoError(t, err, "UnlockUser should not return an error, but did") + }) + } +} + // newUserServiceClient returns a new gRPC client for the CLI service. func newUserServiceClient(t *testing.T, dbFile string) (client authd.UserServiceClient) { t.Helper() diff --git a/internal/testutils/args.go b/internal/testutils/args.go index 659e8a9dd8..af74211dfe 100644 --- a/internal/testutils/args.go +++ b/internal/testutils/args.go @@ -67,6 +67,26 @@ func IsRace() bool { return isRace } +// GoBuildFlags returns the Go build flags that should be used when building binaries in tests. +// It includes flags for coverage, address sanitizer, and race detection if they are enabled +// in the current test environment. +// +// Note: The flags returned by this function must be the first arguments to the `go build` command, +// because -cover is a "positional flag". +func GoBuildFlags() []string { + var flags []string + if CoverDirForTests() != "" { + flags = append(flags, "-cover") + } + if IsAsan() { + flags = append(flags, "-asan") + } + if IsRace() { + flags = append(flags, "-race") + } + return flags +} + // SleepMultiplier returns the sleep multiplier to be used in tests. func SleepMultiplier() float64 { sleepMultiplierOnce.Do(func() { diff --git a/internal/testutils/authctl.go b/internal/testutils/authctl.go new file mode 100644 index 0000000000..8ede48db35 --- /dev/null +++ b/internal/testutils/authctl.go @@ -0,0 +1,32 @@ +package testutils + +import ( + "fmt" + "os" + "os/exec" + "path/filepath" +) + +// BuildAuthctl builds the authctl binary in a temporary directory for testing purposes. +func BuildAuthctl() (binaryPath string, cleanup func(), err error) { + tempDir, err := os.MkdirTemp("", "authctl") + if err != nil { + return "", nil, fmt.Errorf("failed to create temp directory: %w", err) + } + cleanup = func() { os.RemoveAll(tempDir) } + binaryPath = filepath.Join(tempDir, "authctl") + + cmd := exec.Command("go", "build") + cmd.Args = append(cmd.Args, GoBuildFlags()...) + cmd.Args = append(cmd.Args, "-o", binaryPath, "./cmd/authctl") + cmd.Dir = ProjectRoot() + + fmt.Fprintln(os.Stderr, "Running command:", cmd.String()) + if output, err := cmd.CombinedOutput(); err != nil { + cleanup() + fmt.Printf("Command output:\n%s\n", output) + return "", nil, fmt.Errorf("failed to build authctl: %w", err) + } + + return binaryPath, cleanup, nil +} diff --git a/internal/testutils/daemon.go b/internal/testutils/daemon.go index 24620cb73e..5274bd2e8a 100644 --- a/internal/testutils/daemon.go +++ b/internal/testutils/daemon.go @@ -113,9 +113,24 @@ func WithGroupFileOutput(groupFile string) DaemonOption { } } -// RunDaemon runs the daemon in a separate process and returns the socket path and a channel that will be closed when -// the daemon stops. -func RunDaemon(ctx context.Context, t *testing.T, execPath string, args ...DaemonOption) (socketPath string, stopped chan struct{}) { +// WithCurrentUserAsRoot configures the daemon to accept the current user as root when checking permissions. +// This is useful for integration tests where the current user is not root, but we want to +// test the behavior as if it were root. +var WithCurrentUserAsRoot DaemonOption = func(o *daemonOptions) { + o.env = append(o.env, "AUTHD_INTEGRATIONTESTS_CURRENT_USER_AS_ROOT=1") +} + +// StartDaemon starts the daemon in a separate process and returns the socket path. +func StartDaemon(t *testing.T, execPath string, args ...DaemonOption) (socketPath string) { + t.Helper() + + socketPath, cancelFunc := StartDaemonWithCancel(t, execPath, args...) + t.Cleanup(cancelFunc) + return socketPath +} + +// StartDaemonWithCancel starts the daemon in a separate process and returns the socket path and a cancel function. +func StartDaemonWithCancel(t *testing.T, execPath string, args ...DaemonOption) (socketPath string, cancelFunc func()) { t.Helper() opts := &daemonOptions{} @@ -151,9 +166,12 @@ paths: configPath := filepath.Join(tempDir, "testconfig.yaml") require.NoError(t, os.WriteFile(configPath, []byte(config), 0600), "Setup: failed to create config file for tests") - var cancel context.CancelCauseFunc - if opts.pidFile != "" { - ctx, cancel = context.WithCancelCause(ctx) + stopped := make(chan struct{}) + ctx, cancel := context.WithCancelCause(context.Background()) + cancelFunc = func() { + t.Log("Stopping daemon...") + cancel(nil) + <-stopped } // #nosec:G204 - we control the command arguments in tests @@ -169,13 +187,14 @@ paths: } // Start the daemon - stopped = make(chan struct{}) processPid := make(chan int) go func() { defer close(stopped) var b bytes.Buffer cmd.Stdout = &b cmd.Stderr = &b + + t.Logf("Setup: starting daemon with command: %s", cmd.String()) err := cmd.Start() require.NoError(t, err, "Setup: daemon cannot start %v", cmd.Args) if opts.pidFile != "" { @@ -246,11 +265,11 @@ paths: }() } - return opts.socketPath, stopped + return opts.socketPath, cancelFunc } -// BuildDaemon builds the daemon executable and returns the binary path. -func BuildDaemon(extraArgs ...string) (execPath string, cleanup func(), err error) { +// BuildDaemonWithExampleBroker builds the daemon executable and returns the binary path. +func BuildDaemonWithExampleBroker() (execPath string, cleanup func(), err error) { projectRoot := ProjectRoot() tempDir, err := os.MkdirTemp("", "authd-tests-daemon") @@ -262,20 +281,12 @@ func BuildDaemon(extraArgs ...string) (execPath string, cleanup func(), err erro execPath = filepath.Join(tempDir, "authd") cmd := exec.Command("go", "build") cmd.Dir = projectRoot - if CoverDirForTests() != "" { - // -cover is a "positional flag", so it needs to come right after the "build" command. - cmd.Args = append(cmd.Args, "-cover") - } - if IsAsan() { - cmd.Args = append(cmd.Args, "-asan") - } - if IsRace() { - cmd.Args = append(cmd.Args, "-race") - } + cmd.Args = append(cmd.Args, GoBuildFlags()...) cmd.Args = append(cmd.Args, "-gcflags=all=-N -l") - cmd.Args = append(cmd.Args, extraArgs...) + cmd.Args = append(cmd.Args, "-tags=withexamplebroker,integrationtests") cmd.Args = append(cmd.Args, "-o", execPath, "./cmd/authd") + fmt.Fprintln(os.Stderr, "Running command:", cmd.String()) if out, err := cmd.CombinedOutput(); err != nil { cleanup() return "", nil, fmt.Errorf("failed to build daemon(%v): %s", err, out) diff --git a/internal/users/db/db_test.go b/internal/users/db/db_test.go index d6061cf5c3..b26ff85e11 100644 --- a/internal/users/db/db_test.go +++ b/internal/users/db/db_test.go @@ -118,8 +118,8 @@ func TestDatabaseRemovedWhenSchemaCreationFails(t *testing.T) { func TestMigrationToLowercaseUserAndGroupNames(t *testing.T) { // Create a database from the testdata dbDir := t.TempDir() - dbFile := "one_users_multiple_groups_with_uppercase.db.yaml" - err := db.Z_ForTests_CreateDBFromYAML(filepath.Join("testdata", dbFile), dbDir) + sqlDump := "TestMigrationToLowercaseUserAndGroupNames/one_users_multiple_groups_with_uppercase.sql" + err := db.Z_ForTests_CreateDBFromDump(filepath.Join("testdata", sqlDump), dbDir) require.NoError(t, err, "Setup: could not create database from testdata") // Create a temporary user group file for testing @@ -189,8 +189,8 @@ func TestMigrationToLowercaseUserAndGroupNamesEmptyDB(t *testing.T) { func TestMigrationToLowercaseUserAndGroupNamesAlreadyUpdated(t *testing.T) { // Create a database from the testdata dbDir := t.TempDir() - dbFile := "one_users_multiple_groups_with_uppercase.db.yaml" - err := db.Z_ForTests_CreateDBFromYAML(filepath.Join("testdata", dbFile), dbDir) + sqlDump := "TestMigrationToLowercaseUserAndGroupNames/one_users_multiple_groups_with_uppercase.sql" + err := db.Z_ForTests_CreateDBFromDump(filepath.Join("testdata", sqlDump), dbDir) require.NoError(t, err, "Setup: could not create database from testdata") // Create a temporary user group file for testing @@ -220,8 +220,8 @@ func TestMigrationToLowercaseUserAndGroupNamesAlreadyUpdated(t *testing.T) { func TestMigrationToLowercaseUserAndGroupNamesWithSymlinkedGroupFile(t *testing.T) { // Create a database from the testdata dbDir := t.TempDir() - dbFile := "one_users_multiple_groups_with_uppercase.db.yaml" - err := db.Z_ForTests_CreateDBFromYAML(filepath.Join("testdata", dbFile), dbDir) + sqlDump := "TestMigrationToLowercaseUserAndGroupNames/one_users_multiple_groups_with_uppercase.sql" + err := db.Z_ForTests_CreateDBFromDump(filepath.Join("testdata", sqlDump), dbDir) require.NoError(t, err, "Setup: could not create database from testdata") // Create a temporary user group file for testing @@ -271,8 +271,8 @@ func TestMigrationToLowercaseUserAndGroupNamesWithSymlinkedGroupFile(t *testing. func TestMigrationToLowercaseUserAndGroupNamesWithPreviousBackup(t *testing.T) { // Create a database from the testdata dbDir := t.TempDir() - dbFile := "one_users_multiple_groups_fully_uppercase.db.yaml" - err := db.Z_ForTests_CreateDBFromYAML(filepath.Join("testdata", dbFile), dbDir) + sqlDump := "TestMigrationToLowercaseUserAndGroupNames/one_users_multiple_groups_fully_uppercase.sql" + err := db.Z_ForTests_CreateDBFromDump(filepath.Join("testdata", sqlDump), dbDir) require.NoError(t, err, "Setup: could not create database from testdata") // Create a temporary user group file for testing @@ -318,8 +318,8 @@ func TestMigrationToLowercaseUserAndGroupNamesWithPreviousBackup(t *testing.T) { func TestMigrationToLowercaseUserAndGroupNamesWithSymlinkedPreviousBackup(t *testing.T) { // Create a database from the testdata dbDir := t.TempDir() - dbFile := "one_users_multiple_groups_fully_uppercase.db.yaml" - err := db.Z_ForTests_CreateDBFromYAML(filepath.Join("testdata", dbFile), dbDir) + sqlDump := "TestMigrationToLowercaseUserAndGroupNames/one_users_multiple_groups_fully_uppercase.sql" + err := db.Z_ForTests_CreateDBFromDump(filepath.Join("testdata", sqlDump), dbDir) require.NoError(t, err, "Setup: could not create database from testdata") // Create a temporary user group file for testing @@ -375,8 +375,8 @@ func TestMigrationToLowercaseUserAndGroupNamesWithSymlinkedPreviousBackup(t *tes func TestMigrationToLowercaseUserAndGroupNamesFails(t *testing.T) { // Create a database from the testdata dbDir := t.TempDir() - dbFile := "one_users_multiple_groups_fully_uppercase.db.yaml" - err := db.Z_ForTests_CreateDBFromYAML(filepath.Join("testdata", dbFile), dbDir) + sqlDump := "TestMigrationToLowercaseUserAndGroupNames/one_users_multiple_groups_fully_uppercase.sql" + err := db.Z_ForTests_CreateDBFromDump(filepath.Join("testdata", sqlDump), dbDir) require.NoError(t, err, "Setup: could not create database from testdata") // Create a temporary user group file for testing @@ -413,8 +413,8 @@ func TestMigrationToLowercaseUserAndGroupNamesFails(t *testing.T) { func TestMigrationToLowercaseUserAndGroupNamesWithBackupFailure(t *testing.T) { // Create a database from the testdata dbDir := t.TempDir() - dbFile := "one_users_multiple_groups_with_uppercase.db.yaml" - err := db.Z_ForTests_CreateDBFromYAML(filepath.Join("testdata", dbFile), dbDir) + sqlDump := "TestMigrationToLowercaseUserAndGroupNames/one_users_multiple_groups_with_uppercase.sql" + err := db.Z_ForTests_CreateDBFromDump(filepath.Join("testdata", sqlDump), dbDir) require.NoError(t, err, "Setup: could not create database from testdata") // Create a temporary user group file for testing @@ -455,6 +455,24 @@ func TestMigrationToLowercaseUserAndGroupNamesWithBackupFailure(t *testing.T) { golden.CheckOrUpdate(t, string(userGroupContent), golden.WithPath("groups")) } +func TestMigrationAddLockedColumnToUsersTable(t *testing.T) { + // Create a database from the testdata + dbDir := t.TempDir() + sqlDump := "TestMigrationAddLockedColumnToUsersTable/one_user_and_group_without_locked_column.sql" + err := db.Z_ForTests_CreateDBFromDump(filepath.Join("testdata", sqlDump), dbDir) + require.NoError(t, err, "Setup: could not create database from testdata") + + // Run the migrations + m, err := db.New(dbDir) + require.NoError(t, err) + + // Check the content of the SQLite database + dbContent, err := db.Z_ForTests_DumpNormalizedYAML(m) + require.NoError(t, err) + + golden.CheckOrUpdate(t, dbContent) +} + func TestUpdateUserEntry(t *testing.T) { t.Parallel() @@ -842,6 +860,24 @@ func TestUpdateBrokerForUser(t *testing.T) { require.Error(t, err, "UpdateBrokerForUser for a nonexistent user should return an error") } +func TestUpdateLockedFieldForUser(t *testing.T) { + t.Parallel() + + c := initDB(t, "one_user_and_group") + + // Update broker for existent user + err := c.UpdateLockedFieldForUser("user1", true) + require.NoError(t, err, "UpdateLockedFieldForUser for an existent user should not return an error") + + // Update broker for existent user with different capitalization + err = c.UpdateLockedFieldForUser("USER1", true) + require.NoError(t, err, "UpdateLockedFieldForUser for an existent user with different capitalization should not return an error") + + // Error when updating broker for nonexistent user + err = c.UpdateLockedFieldForUser("nonexistent", false) + require.Error(t, err, "UpdateLockedFieldForUser for a nonexistent user should return an error") +} + func TestRemoveDb(t *testing.T) { t.Parallel() diff --git a/internal/users/db/migration.go b/internal/users/db/migration.go index 1031cb1a8f..47dde36614 100644 --- a/internal/users/db/migration.go +++ b/internal/users/db/migration.go @@ -171,15 +171,20 @@ var schemaMigrations = []schemaMigration{ err = commitOrRollBackTransaction(err, tx) }() - users, err := allUsers(tx) + rows, err := tx.Query(`SELECT name FROM users`) if err != nil { return fmt.Errorf("failed to get users from database: %w", err) } + defer rows.Close() var oldNames, newNames []string - for _, u := range users { - oldNames = append(oldNames, u.Name) - newNames = append(newNames, strings.ToLower(u.Name)) + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + return fmt.Errorf("failed to scan user name: %w", err) + } + oldNames = append(oldNames, name) + newNames = append(newNames, strings.ToLower(name)) } if err := renameUsersInGroupFile(oldNames, newNames); err != nil { @@ -199,6 +204,40 @@ var schemaMigrations = []schemaMigration{ return err }, }, + { + description: "Add column 'locked' to users table", + migrate: func(m *Manager) error { + // Start a transaction to ensure atomicity + tx, err := m.db.Begin() + if err != nil { + return fmt.Errorf("failed to start transaction: %w", err) + } + + // Ensure the transaction is committed or rolled back + defer func() { + err = commitOrRollBackTransaction(err, tx) + }() + + // Check if the 'locked' column already exists + var exists bool + err = tx.QueryRow("SELECT EXISTS(SELECT 1 FROM pragma_table_info('users') WHERE name = 'locked')").Scan(&exists) + if err != nil { + return fmt.Errorf("failed to check if 'locked' column exists: %w", err) + } + if exists { + log.Debug(context.Background(), "'locked' column already exists in users table, skipping migration") + return nil + } + + // Add the 'locked' column to the users table + _, err = tx.Exec("ALTER TABLE users ADD COLUMN locked BOOLEAN DEFAULT FALSE") + if err != nil { + return fmt.Errorf("failed to add 'locked' column to users table: %w", err) + } + + return nil + }, + }, } func (m *Manager) maybeApplyMigrations() error { diff --git a/internal/users/db/sql/create_schema.sql b/internal/users/db/sql/create_schema.sql index 35351d6d64..f08c89e399 100644 --- a/internal/users/db/sql/create_schema.sql +++ b/internal/users/db/sql/create_schema.sql @@ -5,7 +5,8 @@ CREATE TABLE IF NOT EXISTS users ( gecos TEXT DEFAULT "", dir TEXT DEFAULT "", shell TEXT DEFAULT "/bin/bash", - broker_id TEXT DEFAULT "" + broker_id TEXT DEFAULT "", + locked BOOLEAN DEFAULT FALSE ); CREATE UNIQUE INDEX "idx_user_name" ON users ("name"); diff --git a/internal/users/db/testdata/TestMigrationAddLockedColumnToUsersTable/one_user_and_group_without_locked_column.sql b/internal/users/db/testdata/TestMigrationAddLockedColumnToUsersTable/one_user_and_group_without_locked_column.sql new file mode 100644 index 0000000000..6272520784 --- /dev/null +++ b/internal/users/db/testdata/TestMigrationAddLockedColumnToUsersTable/one_user_and_group_without_locked_column.sql @@ -0,0 +1,40 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE users ( + name TEXT NOT NULL, -- Uniqueness is enforced by the index below + uid INT PRIMARY KEY, -- Uniqueness and not NULL is enforced by PRIMARY KEY + gid INT NOT NULL, + gecos TEXT DEFAULT "", + dir TEXT DEFAULT "", + shell TEXT DEFAULT "/bin/bash", + broker_id TEXT DEFAULT "" +); +INSERT INTO users VALUES('user1',1111,11111,replace('User1 gecos\nOn multiple lines','\n',char(10)),'/home/user1','/bin/bash','broker-id'); +CREATE TABLE GROUPS ( + name TEXT NOT NULL, -- Uniqueness is enforced by the index below + gid INT PRIMARY KEY, -- Uniqueness and not NULL is enforced by PRIMARY KEY + ugid INT NOT NULL -- Uniqueness is enforced by the index below +); +INSERT INTO "GROUPS" VALUES('group1',11111,12345678); +CREATE TABLE users_to_groups ( + uid INT NOT NULL, + gid INT NOT NULL, + PRIMARY KEY (uid, gid), + FOREIGN KEY (uid) REFERENCES users (uid) ON DELETE CASCADE, + FOREIGN KEY (gid) REFERENCES GROUPS (gid) ON DELETE CASCADE +); +INSERT INTO users_to_groups VALUES(1111,11111); +CREATE TABLE users_to_local_groups ( + uid INT NOT NULL, + group_name TEXT NOT NULL, + PRIMARY KEY (uid, group_name), + FOREIGN KEY (uid) REFERENCES users (uid) ON DELETE CASCADE +); +CREATE TABLE schema_version ( + version INT PRIMARY KEY +); +INSERT INTO schema_version VALUES(1); +CREATE UNIQUE INDEX "idx_user_name" ON users ("name"); +CREATE UNIQUE INDEX "idx_group_name" ON GROUPS ("name"); +CREATE UNIQUE INDEX "idx_group_ugid" ON GROUPS ("ugid"); +COMMIT; diff --git a/internal/users/db/testdata/TestMigrationToLowercaseUserAndGroupNames/one_users_multiple_groups_fully_uppercase.sql b/internal/users/db/testdata/TestMigrationToLowercaseUserAndGroupNames/one_users_multiple_groups_fully_uppercase.sql new file mode 100644 index 0000000000..fb50c5c091 --- /dev/null +++ b/internal/users/db/testdata/TestMigrationToLowercaseUserAndGroupNames/one_users_multiple_groups_fully_uppercase.sql @@ -0,0 +1,42 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE users ( + name TEXT NOT NULL, -- Uniqueness is enforced by the index below + uid INT PRIMARY KEY, -- Uniqueness and not NULL is enforced by PRIMARY KEY + gid INT NOT NULL, + gecos TEXT DEFAULT "", + dir TEXT DEFAULT "", + shell TEXT DEFAULT "/bin/bash", + broker_id TEXT DEFAULT "" +); +INSERT INTO users VALUES('TESTUSER',1111,11111,'testuser gecos','/home/testuser','/bin/bash','broker-id'); +CREATE TABLE GROUPS ( + name TEXT NOT NULL, -- Uniqueness is enforced by the index below + gid INT PRIMARY KEY, -- Uniqueness and not NULL is enforced by PRIMARY KEY + ugid INT NOT NULL -- Uniqueness is enforced by the index below +); +INSERT INTO "GROUPS" VALUES('testgroup',11111,12345678); +INSERT INTO "GROUPS" VALUES('TESTGROUP',22222,56781234); +CREATE TABLE users_to_groups ( + uid INT NOT NULL, + gid INT NOT NULL, + PRIMARY KEY (uid, gid), + FOREIGN KEY (uid) REFERENCES users (uid) ON DELETE CASCADE, + FOREIGN KEY (gid) REFERENCES GROUPS (gid) ON DELETE CASCADE +); +INSERT INTO users_to_groups VALUES(1111,11111); +INSERT INTO users_to_groups VALUES(1111,22222); +CREATE TABLE users_to_local_groups ( + uid INT NOT NULL, + group_name TEXT NOT NULL, + PRIMARY KEY (uid, group_name), + FOREIGN KEY (uid) REFERENCES users (uid) ON DELETE CASCADE +); +CREATE TABLE schema_version ( + version INT PRIMARY KEY +); +INSERT INTO schema_version VALUES(0); +CREATE UNIQUE INDEX "idx_user_name" ON users ("name"); +CREATE UNIQUE INDEX "idx_group_name" ON GROUPS ("name"); +CREATE UNIQUE INDEX "idx_group_ugid" ON GROUPS ("ugid"); +COMMIT; diff --git a/internal/users/db/testdata/TestMigrationToLowercaseUserAndGroupNames/one_users_multiple_groups_with_uppercase.sql b/internal/users/db/testdata/TestMigrationToLowercaseUserAndGroupNames/one_users_multiple_groups_with_uppercase.sql new file mode 100644 index 0000000000..53664d2c6a --- /dev/null +++ b/internal/users/db/testdata/TestMigrationToLowercaseUserAndGroupNames/one_users_multiple_groups_with_uppercase.sql @@ -0,0 +1,42 @@ +PRAGMA foreign_keys=OFF; +BEGIN TRANSACTION; +CREATE TABLE users ( + name TEXT NOT NULL, -- Uniqueness is enforced by the index below + uid INT PRIMARY KEY, -- Uniqueness and not NULL is enforced by PRIMARY KEY + gid INT NOT NULL, + gecos TEXT DEFAULT "", + dir TEXT DEFAULT "", + shell TEXT DEFAULT "/bin/bash", + broker_id TEXT DEFAULT "" +); +INSERT INTO users VALUES('TestUser',1111,11111,'testuser gecos','/home/testuser','/bin/bash','broker-id'); +CREATE TABLE GROUPS ( + name TEXT NOT NULL, -- Uniqueness is enforced by the index below + gid INT PRIMARY KEY, -- Uniqueness and not NULL is enforced by PRIMARY KEY + ugid INT NOT NULL -- Uniqueness is enforced by the index below +); +INSERT INTO "GROUPS" VALUES('testgroup',11111,12345678); +INSERT INTO "GROUPS" VALUES('TestGroup',22222,56781234); +CREATE TABLE users_to_groups ( + uid INT NOT NULL, + gid INT NOT NULL, + PRIMARY KEY (uid, gid), + FOREIGN KEY (uid) REFERENCES users (uid) ON DELETE CASCADE, + FOREIGN KEY (gid) REFERENCES GROUPS (gid) ON DELETE CASCADE +); +INSERT INTO users_to_groups VALUES(1111,11111); +INSERT INTO users_to_groups VALUES(1111,22222); +CREATE TABLE users_to_local_groups ( + uid INT NOT NULL, + group_name TEXT NOT NULL, + PRIMARY KEY (uid, group_name), + FOREIGN KEY (uid) REFERENCES users (uid) ON DELETE CASCADE +); +CREATE TABLE schema_version ( + version INT PRIMARY KEY +); +INSERT INTO schema_version VALUES(0); +CREATE UNIQUE INDEX "idx_user_name" ON users ("name"); +CREATE UNIQUE INDEX "idx_group_name" ON GROUPS ("name"); +CREATE UNIQUE INDEX "idx_group_ugid" ON GROUPS ("ugid"); +COMMIT; diff --git a/internal/users/db/testdata/golden/TestDeleteUser/Deleting_existing_user_keeps_other_group_members_intact b/internal/users/db/testdata/golden/TestDeleteUser/Deleting_existing_user_keeps_other_group_members_intact index d5bf84ebf8..5b781502ba 100644 --- a/internal/users/db/testdata/golden/TestDeleteUser/Deleting_existing_user_keeps_other_group_members_intact +++ b/internal/users/db/testdata/golden/TestDeleteUser/Deleting_existing_user_keeps_other_group_members_intact @@ -48,4 +48,4 @@ users_to_groups: gid: 44444 - uid: 4444 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestDeleteUser/Deleting_last_user_from_a_group_keeps_the_group_record b/internal/users/db/testdata/golden/TestDeleteUser/Deleting_last_user_from_a_group_keeps_the_group_record index 2ff9b6e1f5..bbfb783e2c 100644 --- a/internal/users/db/testdata/golden/TestDeleteUser/Deleting_last_user_from_a_group_keeps_the_group_record +++ b/internal/users/db/testdata/golden/TestDeleteUser/Deleting_last_user_from_a_group_keeps_the_group_record @@ -4,4 +4,4 @@ groups: gid: 11111 ugid: "12345678" users_to_groups: [] -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestMigrationAddLockedColumnToUsersTable b/internal/users/db/testdata/golden/TestMigrationAddLockedColumnToUsersTable new file mode 100644 index 0000000000..df1e19d597 --- /dev/null +++ b/internal/users/db/testdata/golden/TestMigrationAddLockedColumnToUsersTable @@ -0,0 +1,18 @@ +users: + - name: user1 + uid: 1111 + gid: 11111 + gecos: |- + User1 gecos + On multiple lines + dir: /home/user1 + shell: /bin/bash + broker_id: broker-id +groups: + - name: group1 + gid: 11111 + ugid: "12345678" +users_to_groups: + - uid: 1111 + gid: 11111 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNames/db b/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNames/db index da365938b4..c2312b979b 100644 --- a/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNames/db +++ b/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNames/db @@ -13,4 +13,4 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesAlreadyUpdated/db b/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesAlreadyUpdated/db index da365938b4..c2312b979b 100644 --- a/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesAlreadyUpdated/db +++ b/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesAlreadyUpdated/db @@ -13,4 +13,4 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesEmptyDB/db b/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesEmptyDB/db index 8bd98be48c..03de3ff950 100644 --- a/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesEmptyDB/db +++ b/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesEmptyDB/db @@ -1,4 +1,4 @@ users: [] groups: [] users_to_groups: [] -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithBackupFailure/db b/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithBackupFailure/db index da365938b4..c2312b979b 100644 --- a/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithBackupFailure/db +++ b/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithBackupFailure/db @@ -13,4 +13,4 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithPreviousBackup/db b/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithPreviousBackup/db index da365938b4..c2312b979b 100644 --- a/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithPreviousBackup/db +++ b/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithPreviousBackup/db @@ -13,4 +13,4 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithSymlinkedGroupFile/db b/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithSymlinkedGroupFile/db index da365938b4..c2312b979b 100644 --- a/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithSymlinkedGroupFile/db +++ b/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithSymlinkedGroupFile/db @@ -13,4 +13,4 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithSymlinkedPreviousBackup/db b/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithSymlinkedPreviousBackup/db index da365938b4..c2312b979b 100644 --- a/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithSymlinkedPreviousBackup/db +++ b/internal/users/db/testdata/golden/TestMigrationToLowercaseUserAndGroupNamesWithSymlinkedPreviousBackup/db @@ -13,4 +13,4 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestNew/New_with_already_existing_database b/internal/users/db/testdata/golden/TestNew/New_with_already_existing_database index 9a59a4ead0..9c042d2317 100644 --- a/internal/users/db/testdata/golden/TestNew/New_with_already_existing_database +++ b/internal/users/db/testdata/golden/TestNew/New_with_already_existing_database @@ -61,4 +61,4 @@ users_to_groups: gid: 44444 - uid: 4444 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestNew/New_without_any_initialized_database b/internal/users/db/testdata/golden/TestNew/New_without_any_initialized_database index 8bd98be48c..03de3ff950 100644 --- a/internal/users/db/testdata/golden/TestNew/New_without_any_initialized_database +++ b/internal/users/db/testdata/golden/TestNew/New_without_any_initialized_database @@ -1,4 +1,4 @@ users: [] groups: [] users_to_groups: [] -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Add_user_to_group_from_another_user b/internal/users/db/testdata/golden/TestUpdateUserEntry/Add_user_to_group_from_another_user index 827c703025..71f41b4662 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Add_user_to_group_from_another_user +++ b/internal/users/db/testdata/golden/TestUpdateUserEntry/Add_user_to_group_from_another_user @@ -60,4 +60,4 @@ users_to_groups: gid: 44444 - uid: 4444 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Insert_new_user b/internal/users/db/testdata/golden/TestUpdateUserEntry/Insert_new_user index 362a15f68b..1ead371466 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Insert_new_user +++ b/internal/users/db/testdata/golden/TestUpdateUserEntry/Insert_new_user @@ -14,4 +14,4 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Insert_new_user_without_optional_gecos_field b/internal/users/db/testdata/golden/TestUpdateUserEntry/Insert_new_user_without_optional_gecos_field index db54d238d5..3d09a971c2 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Insert_new_user_without_optional_gecos_field +++ b/internal/users/db/testdata/golden/TestUpdateUserEntry/Insert_new_user_without_optional_gecos_field @@ -12,4 +12,4 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Remove_group_from_user b/internal/users/db/testdata/golden/TestUpdateUserEntry/Remove_group_from_user index 36b5459a96..647653fdc9 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Remove_group_from_user +++ b/internal/users/db/testdata/golden/TestUpdateUserEntry/Remove_group_from_user @@ -17,4 +17,4 @@ groups: users_to_groups: - uid: 1111 gid: 22222 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Remove_user_from_a_group_still_part_from_another_user b/internal/users/db/testdata/golden/TestUpdateUserEntry/Remove_user_from_a_group_still_part_from_another_user index 411a1de501..a5079149a7 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Remove_user_from_a_group_still_part_from_another_user +++ b/internal/users/db/testdata/golden/TestUpdateUserEntry/Remove_user_from_a_group_still_part_from_another_user @@ -58,4 +58,4 @@ users_to_groups: gid: 44444 - uid: 4444 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_only_user_even_if_we_have_multiple_of_them b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_only_user_even_if_we_have_multiple_of_them index 187cd238b6..03418546c1 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_only_user_even_if_we_have_multiple_of_them +++ b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_only_user_even_if_we_have_multiple_of_them @@ -58,4 +58,4 @@ users_to_groups: gid: 44444 - uid: 4444 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_adding_a_new_default_group b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_adding_a_new_default_group index e9f01e9d24..6b7a9c27c0 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_adding_a_new_default_group +++ b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_adding_a_new_default_group @@ -19,4 +19,4 @@ users_to_groups: gid: 11111 - uid: 1111 gid: 22222 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_adding_a_new_group b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_adding_a_new_group index cee0af7d4b..e4576a7e71 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_adding_a_new_group +++ b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_adding_a_new_group @@ -19,4 +19,4 @@ users_to_groups: gid: 11111 - uid: 1111 gid: 22222 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_adding_a_new_local_group b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_adding_a_new_local_group index 362a15f68b..1ead371466 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_adding_a_new_local_group +++ b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_adding_a_new_local_group @@ -14,4 +14,4 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_changing_attributes b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_changing_attributes index 244a44410e..5d1fe51aeb 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_changing_attributes +++ b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_changing_attributes @@ -12,4 +12,4 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_removing_optional_gecos_field_if_not_set b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_removing_optional_gecos_field_if_not_set index db54d238d5..3d09a971c2 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_removing_optional_gecos_field_if_not_set +++ b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_removing_optional_gecos_field_if_not_set @@ -12,4 +12,4 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_renaming_a_group b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_renaming_a_group index 40eb47d5c3..fbecc4550e 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_renaming_a_group +++ b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_by_renaming_a_group @@ -14,4 +14,4 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_does_not_change_homedir_if_it_exists b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_does_not_change_homedir_if_it_exists index 362a15f68b..1ead371466 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_does_not_change_homedir_if_it_exists +++ b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_does_not_change_homedir_if_it_exists @@ -14,4 +14,4 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_does_not_change_shell_if_it_exists b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_does_not_change_shell_if_it_exists index 362a15f68b..1ead371466 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_does_not_change_shell_if_it_exists +++ b/internal/users/db/testdata/golden/TestUpdateUserEntry/Update_user_does_not_change_shell_if_it_exists @@ -14,4 +14,4 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/golden/TestUpdateUserEntry/Updating_user_with_different_capitalization b/internal/users/db/testdata/golden/TestUpdateUserEntry/Updating_user_with_different_capitalization index 362a15f68b..1ead371466 100644 --- a/internal/users/db/testdata/golden/TestUpdateUserEntry/Updating_user_with_different_capitalization +++ b/internal/users/db/testdata/golden/TestUpdateUserEntry/Updating_user_with_different_capitalization @@ -14,4 +14,4 @@ groups: users_to_groups: - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/db/testdata/one_users_multiple_groups_fully_uppercase.db.yaml b/internal/users/db/testdata/one_users_multiple_groups_fully_uppercase.db.yaml deleted file mode 100644 index 52e8d06c22..0000000000 --- a/internal/users/db/testdata/one_users_multiple_groups_fully_uppercase.db.yaml +++ /dev/null @@ -1,21 +0,0 @@ -users: - - name: TESTUSER - uid: 1111 - gid: 11111 - gecos: testuser gecos - dir: /home/testuser - shell: /bin/bash - broker_id: broker-id -groups: - - name: testgroup - gid: 11111 - ugid: "12345678" - - name: TESTGROUP - gid: 22222 - ugid: "56781234" -users_to_groups: - - uid: 1111 - gid: 11111 - - uid: 1111 - gid: 22222 -schema_version: 0 diff --git a/internal/users/db/testdata/one_users_multiple_groups_with_uppercase.db.yaml b/internal/users/db/testdata/one_users_multiple_groups_with_uppercase.db.yaml deleted file mode 100644 index 1dd632c19c..0000000000 --- a/internal/users/db/testdata/one_users_multiple_groups_with_uppercase.db.yaml +++ /dev/null @@ -1,21 +0,0 @@ -users: - - name: TestUser - uid: 1111 - gid: 11111 - gecos: testuser gecos - dir: /home/testuser - shell: /bin/bash - broker_id: broker-id -groups: - - name: testgroup - gid: 11111 - ugid: "12345678" - - name: TestGroup - gid: 22222 - ugid: "56781234" -users_to_groups: - - uid: 1111 - gid: 11111 - - uid: 1111 - gid: 22222 -schema_version: 0 diff --git a/internal/users/db/testutils.go b/internal/users/db/testutils.go index 73499182a8..4ad09840ae 100644 --- a/internal/users/db/testutils.go +++ b/internal/users/db/testutils.go @@ -5,6 +5,7 @@ package db import ( "context" + "database/sql" "errors" "fmt" "io" @@ -12,6 +13,8 @@ import ( "path/filepath" "sort" + "github.com/ubuntu/authd/internal/consts" + "github.com/ubuntu/authd/internal/fileutils" "github.com/ubuntu/authd/internal/testsdetection" "github.com/ubuntu/authd/log" "gopkg.in/yaml.v3" @@ -200,3 +203,52 @@ func createDBFromYAMLReader(r io.Reader, destDir string) (err error) { log.Debug(context.Background(), "Database created") return nil } + +// Z_ForTests_CreateDBFromDump creates the database from the provided SQLite dump file. +// +// nolint:revive,nolintlint // We want to use underscores in the function name here. +func Z_ForTests_CreateDBFromDump(dumpFile, destDir string) error { + testsdetection.MustBeTesting() + + dumpFile, err := filepath.Abs(dumpFile) + if err != nil { + return err + } + + log.Debugf(context.Background(), "Creating SQLite database from dump %s", dumpFile) + + dbPath := filepath.Join(destDir, consts.DefaultDatabaseFileName) + if err := os.MkdirAll(destDir, 0700); err != nil { + return fmt.Errorf("could not create database directory %s: %w", destDir, err) + } + if err := fileutils.Touch(dbPath); err != nil { + return fmt.Errorf("could not create database file %s: %w", dbPath, err) + } + + db, err := sql.Open("sqlite3", fmt.Sprintf("file:%s?cache=shared", dbPath)) + if err != nil { + return fmt.Errorf("could not open database file %s: %w", dbPath, err) + } + + // Enable foreign key support (this needs to be done for each connection, so we can't do it in the schema). + _, err = db.Exec("PRAGMA foreign_keys = ON;") + if err != nil { + return fmt.Errorf("failed to enable foreign keys: %w", err) + } + + dumpContent, err := os.ReadFile(dumpFile) + if err != nil { + return fmt.Errorf("could not read dump file %s: %w", dumpFile, err) + } + // Execute the dump content. + _, err = db.Exec(string(dumpContent)) + if err != nil { + return fmt.Errorf("could not execute dump content: %w", err) + } + + if err := db.Close(); err != nil { + return fmt.Errorf("could not close database: %w", err) + } + + return nil +} diff --git a/internal/users/db/update.go b/internal/users/db/update.go index 8b036596f5..c2bd5a7fe8 100644 --- a/internal/users/db/update.go +++ b/internal/users/db/update.go @@ -182,3 +182,24 @@ func (m *Manager) UpdateBrokerForUser(username, brokerID string) error { return nil } + +// UpdateLockedFieldForUser sets the "locked" field of a user record. +func (m *Manager) UpdateLockedFieldForUser(username string, locked bool) error { + // authd uses lowercase usernames + username = strings.ToLower(username) + + query := `UPDATE users SET locked = ? WHERE name = ?` + res, err := m.db.Exec(query, locked, username) + if err != nil { + return fmt.Errorf("failed to update locked field for user: %w", err) + } + rowsAffected, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("failed to get rows affected: %w", err) + } + if rowsAffected == 0 { + return NewUserNotFoundError(username) + } + + return nil +} diff --git a/internal/users/db/users.go b/internal/users/db/users.go index 53218350c6..90369ca933 100644 --- a/internal/users/db/users.go +++ b/internal/users/db/users.go @@ -10,9 +10,9 @@ import ( "github.com/ubuntu/authd/log" ) -const allUserColumns = "name, uid, gid, gecos, dir, shell, broker_id" -const publicUserColumns = "name, uid, gid, gecos, dir, shell, broker_id" -const allUserColumnsWithPlaceholders = "name = ?, uid = ?, gid = ?, gecos = ?, dir = ?, shell = ?, broker_id = ?" +const allUserColumns = "name, uid, gid, gecos, dir, shell, broker_id, locked" +const publicUserColumns = "name, uid, gid, gecos, dir, shell, broker_id, locked" +const allUserColumnsWithPlaceholders = "name = ?, uid = ?, gid = ?, gecos = ?, dir = ?, shell = ?, broker_id = ?, locked = ?" // UserRow represents a user row in the database. type UserRow struct { @@ -25,6 +25,8 @@ type UserRow struct { // BrokerID specifies the broker the user last successfully authenticated with. BrokerID string `yaml:"broker_id,omitempty"` + + Locked bool `yaml:"locked,omitempty"` } // NewUserRow creates a new UserRow. @@ -49,7 +51,7 @@ func userByID(db queryable, uid uint32) (UserRow, error) { row := db.QueryRow(query, uid) var u UserRow - err := row.Scan(&u.Name, &u.UID, &u.GID, &u.Gecos, &u.Dir, &u.Shell, &u.BrokerID) + err := row.Scan(&u.Name, &u.UID, &u.GID, &u.Gecos, &u.Dir, &u.Shell, &u.BrokerID, &u.Locked) if errors.Is(err, sql.ErrNoRows) { return UserRow{}, NewUIDNotFoundError(uid) } @@ -73,7 +75,7 @@ func userByName(db queryable, name string) (UserRow, error) { row := db.QueryRow(query, name) var u UserRow - err := row.Scan(&u.Name, &u.UID, &u.GID, &u.Gecos, &u.Dir, &u.Shell, &u.BrokerID) + err := row.Scan(&u.Name, &u.UID, &u.GID, &u.Gecos, &u.Dir, &u.Shell, &u.BrokerID, &u.Locked) if errors.Is(err, sql.ErrNoRows) { return UserRow{}, NewUserNotFoundError(name) } @@ -100,7 +102,7 @@ func allUsers(db queryable) ([]UserRow, error) { var users []UserRow for rows.Next() { var u UserRow - err := rows.Scan(&u.Name, &u.UID, &u.GID, &u.Gecos, &u.Dir, &u.Shell, &u.BrokerID) + err := rows.Scan(&u.Name, &u.UID, &u.GID, &u.Gecos, &u.Dir, &u.Shell, &u.BrokerID, &u.Locked) if err != nil { return nil, fmt.Errorf("scan error: %w", err) } @@ -153,8 +155,8 @@ func userExists(db queryable, u UserRow) (bool, error) { // insertUser inserts a new user into the database. func insertUser(db queryable, u UserRow) error { log.Debugf(context.Background(), "Inserting user %v", u.Name) - query := fmt.Sprintf(`INSERT INTO users (%s) VALUES (?, ?, ?, ?, ?, ?, ?)`, allUserColumns) - _, err := db.Exec(query, u.Name, u.UID, u.GID, u.Gecos, u.Dir, u.Shell, u.BrokerID) + query := fmt.Sprintf(`INSERT INTO users (%s) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, allUserColumns) + _, err := db.Exec(query, u.Name, u.UID, u.GID, u.Gecos, u.Dir, u.Shell, u.BrokerID, u.Locked) if err != nil { return fmt.Errorf("insert user error: %w", err) } @@ -165,7 +167,7 @@ func insertUser(db queryable, u UserRow) error { func updateUserByID(db queryable, u UserRow) error { log.Debugf(context.Background(), "Updating user %v", u.Name) query := fmt.Sprintf(`UPDATE users SET %s WHERE uid = ?`, allUserColumnsWithPlaceholders) - _, err := db.Exec(query, u.Name, u.UID, u.GID, u.Gecos, u.Dir, u.Shell, u.BrokerID, u.UID) + _, err := db.Exec(query, u.Name, u.UID, u.GID, u.Gecos, u.Dir, u.Shell, u.BrokerID, u.Locked, u.UID) if err != nil { return fmt.Errorf("update user error: %w", err) } diff --git a/internal/users/manager.go b/internal/users/manager.go index 232691f796..9817ac1ef0 100644 --- a/internal/users/manager.go +++ b/internal/users/manager.go @@ -468,6 +468,34 @@ func (m *Manager) UpdateBrokerForUser(username, brokerID string) error { return nil } +// LockUser sets the "locked" field to true for the given user. +func (m *Manager) LockUser(username string) error { + if err := m.db.UpdateLockedFieldForUser(username, true); err != nil { + return err + } + + return nil +} + +// UnlockUser sets the "locked" field to false for the given user. +func (m *Manager) UnlockUser(username string) error { + if err := m.db.UpdateLockedFieldForUser(username, false); err != nil { + return err + } + + return nil +} + +// IsUserLocked returns true if the user with the given user name is locked, false otherwise. +func (m *Manager) IsUserLocked(username string) (bool, error) { + u, err := m.db.UserByName(username) + if err != nil { + return false, err + } + + return u.Locked, nil +} + // UserByName returns the user information for the given user name. func (m *Manager) UserByName(username string) (types.UserEntry, error) { usr, err := m.db.UserByName(username) diff --git a/internal/users/manager_test.go b/internal/users/manager_test.go index 61935e9559..69e5ebffa9 100644 --- a/internal/users/manager_test.go +++ b/internal/users/manager_test.go @@ -772,6 +772,98 @@ func TestUpdateBrokerForUser(t *testing.T) { } } +//nolint:dupl // This is not a duplicate test +func TestLockUser(t *testing.T) { + tests := map[string]struct { + username string + + dbFile string + + wantErr bool + wantErrType error + }{ + "Successfully_lock_user": {}, + + "Error_if_user_does_not_exist": {username: "doesnotexist", wantErrType: db.NoDataFoundError{}}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // We don't care about the output of gpasswd in this test, but we still need to mock it. + _ = localgroupstestutils.SetupGroupMock(t, filepath.Join("testdata", "groups", "empty.group")) + + if tc.username == "" { + tc.username = "user1" + } + if tc.dbFile == "" { + tc.dbFile = "multiple_users_and_groups" + } + + dbDir := t.TempDir() + err := db.Z_ForTests_CreateDBFromYAML(filepath.Join("testdata", "db", tc.dbFile+".db.yaml"), dbDir) + require.NoError(t, err, "Setup: could not create database from testdata") + m := newManagerForTests(t, dbDir) + + err = m.LockUser(tc.username) + + requireErrorAssertions(t, err, tc.wantErrType, tc.wantErr) + if tc.wantErrType != nil || tc.wantErr { + return + } + + got, err := db.Z_ForTests_DumpNormalizedYAML(userstestutils.GetManagerDB(m)) + require.NoError(t, err, "Created database should be valid yaml content") + + golden.CheckOrUpdate(t, got) + }) + } +} + +//nolint:dupl // This is not a duplicate test +func TestUnlockUser(t *testing.T) { + tests := map[string]struct { + username string + + dbFile string + + wantErr bool + wantErrType error + }{ + "Successfully_enable_user": {}, + + "Error_if_user_does_not_exist": {username: "doesnotexist", wantErrType: db.NoDataFoundError{}}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + // We don't care about the output of gpasswd in this test, but we still need to mock it. + _ = localgroupstestutils.SetupGroupMock(t, filepath.Join("testdata", "groups", "empty.group")) + + if tc.username == "" { + tc.username = "user1" + } + if tc.dbFile == "" { + tc.dbFile = "locked_user" + } + + dbDir := t.TempDir() + err := db.Z_ForTests_CreateDBFromYAML(filepath.Join("testdata", "db", tc.dbFile+".db.yaml"), dbDir) + require.NoError(t, err, "Setup: could not create database from testdata") + m := newManagerForTests(t, dbDir) + + err = m.UnlockUser(tc.username) + + requireErrorAssertions(t, err, tc.wantErrType, tc.wantErr) + if tc.wantErrType != nil || tc.wantErr { + return + } + + got, err := db.Z_ForTests_DumpNormalizedYAML(userstestutils.GetManagerDB(m)) + require.NoError(t, err, "Created database should be valid yaml content") + + golden.CheckOrUpdate(t, got) + }) + } +} + func TestUserByIDAndName(t *testing.T) { t.Parallel() diff --git a/internal/users/testdata/db/locked_user.db.yaml b/internal/users/testdata/db/locked_user.db.yaml new file mode 100644 index 0000000000..398bd6a9ca --- /dev/null +++ b/internal/users/testdata/db/locked_user.db.yaml @@ -0,0 +1,18 @@ +users: + - name: user1 + uid: 1111 + gid: 11111 + gecos: |- + User1 gecos + On multiple lines + dir: /home/user1 + shell: /bin/bash + broker_id: broker-id + locked: true +groups: + - name: group1 + gid: 11111 + ugid: "12345678" +users_to_groups: + - uid: 1111 + gid: 11111 diff --git a/internal/users/testdata/golden/TestLockUser/Successfully_lock_user b/internal/users/testdata/golden/TestLockUser/Successfully_lock_user new file mode 100644 index 0000000000..e10d01edca --- /dev/null +++ b/internal/users/testdata/golden/TestLockUser/Successfully_lock_user @@ -0,0 +1,65 @@ +users: + - name: user1 + uid: 1111 + gid: 11111 + gecos: |- + User1 gecos + On multiple lines + dir: /home/user1 + shell: /bin/bash + broker_id: broker-id + locked: true + - name: user2 + uid: 2222 + gid: 22222 + gecos: User2 + dir: /home/user2 + shell: /bin/dash + broker_id: broker-id + - name: user3 + uid: 3333 + gid: 33333 + gecos: User3 + dir: /home/user3 + shell: /bin/zsh + broker_id: broker-id + - name: userwithoutbroker + uid: 4444 + gid: 44444 + gecos: userwithoutbroker + dir: /home/userwithoutbroker + shell: /bin/sh +groups: + - name: group1 + gid: 11111 + ugid: "12345678" + - name: group2withoutugid + gid: 22222 + ugid: "" + - name: group3 + gid: 33333 + ugid: "34567812" + - name: group4 + gid: 44444 + ugid: "45678123" + - name: commongroup + gid: 99999 + ugid: "87654321" +users_to_groups: + - uid: 1111 + gid: 11111 + - uid: 1111 + gid: 99999 + - uid: 2222 + gid: 22222 + - uid: 2222 + gid: 99999 + - uid: 3333 + gid: 33333 + - uid: 3333 + gid: 99999 + - uid: 4444 + gid: 44444 + - uid: 4444 + gid: 99999 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_GID_range_next_to_systemd_dynamic_groups b/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_GID_range_next_to_systemd_dynamic_groups index 5d465349a9..7f16f7e742 100644 --- a/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_GID_range_next_to_systemd_dynamic_groups +++ b/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_GID_range_next_to_systemd_dynamic_groups @@ -61,4 +61,4 @@ users_to_groups: gid: 44444 - uid: 4444 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_UID_range_next_to_systemd_dynamic_users b/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_UID_range_next_to_systemd_dynamic_users index 5d465349a9..7f16f7e742 100644 --- a/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_UID_range_next_to_systemd_dynamic_users +++ b/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_UID_range_next_to_systemd_dynamic_users @@ -61,4 +61,4 @@ users_to_groups: gid: 44444 - uid: 4444 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_custom_config b/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_custom_config index 5d465349a9..7f16f7e742 100644 --- a/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_custom_config +++ b/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_custom_config @@ -61,4 +61,4 @@ users_to_groups: gid: 44444 - uid: 4444 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_default_config b/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_default_config index 5d465349a9..7f16f7e742 100644 --- a/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_default_config +++ b/internal/users/testdata/golden/TestNewManager/Successfully_create_manager_with_default_config @@ -61,4 +61,4 @@ users_to_groups: gid: 44444 - uid: 4444 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestNewManager/Warns_creating_manager_with_partially_invalid_GID_ranges b/internal/users/testdata/golden/TestNewManager/Warns_creating_manager_with_partially_invalid_GID_ranges index 5d465349a9..7f16f7e742 100644 --- a/internal/users/testdata/golden/TestNewManager/Warns_creating_manager_with_partially_invalid_GID_ranges +++ b/internal/users/testdata/golden/TestNewManager/Warns_creating_manager_with_partially_invalid_GID_ranges @@ -61,4 +61,4 @@ users_to_groups: gid: 44444 - uid: 4444 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestNewManager/Warns_creating_manager_with_partially_invalid_UID_ranges b/internal/users/testdata/golden/TestNewManager/Warns_creating_manager_with_partially_invalid_UID_ranges index 5d465349a9..7f16f7e742 100644 --- a/internal/users/testdata/golden/TestNewManager/Warns_creating_manager_with_partially_invalid_UID_ranges +++ b/internal/users/testdata/golden/TestNewManager/Warns_creating_manager_with_partially_invalid_UID_ranges @@ -61,4 +61,4 @@ users_to_groups: gid: 44444 - uid: 4444 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestUnlockUser/Successfully_enable_user b/internal/users/testdata/golden/TestUnlockUser/Successfully_enable_user new file mode 100644 index 0000000000..df1e19d597 --- /dev/null +++ b/internal/users/testdata/golden/TestUnlockUser/Successfully_enable_user @@ -0,0 +1,18 @@ +users: + - name: user1 + uid: 1111 + gid: 11111 + gecos: |- + User1 gecos + On multiple lines + dir: /home/user1 + shell: /bin/bash + broker_id: broker-id +groups: + - name: group1 + gid: 11111 + ugid: "12345678" +users_to_groups: + - uid: 1111 + gid: 11111 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestUpdateBrokerForUser/Successfully_update_broker_for_user b/internal/users/testdata/golden/TestUpdateBrokerForUser/Successfully_update_broker_for_user index 8f27fbf1b5..85a2ac6da5 100644 --- a/internal/users/testdata/golden/TestUpdateBrokerForUser/Successfully_update_broker_for_user +++ b/internal/users/testdata/golden/TestUpdateBrokerForUser/Successfully_update_broker_for_user @@ -61,4 +61,4 @@ users_to_groups: gid: 44444 - uid: 4444 gid: 99999 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestUpdateUser/GID_does_not_change_if_group_with_same_UGID_exists b/internal/users/testdata/golden/TestUpdateUser/GID_does_not_change_if_group_with_same_UGID_exists index 4ea8612145..52c8832676 100644 --- a/internal/users/testdata/golden/TestUpdateUser/GID_does_not_change_if_group_with_same_UGID_exists +++ b/internal/users/testdata/golden/TestUpdateUser/GID_does_not_change_if_group_with_same_UGID_exists @@ -17,4 +17,4 @@ users_to_groups: gid: 1111 - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestUpdateUser/GID_does_not_change_if_group_with_same_name_and_empty_UGID_exists b/internal/users/testdata/golden/TestUpdateUser/GID_does_not_change_if_group_with_same_name_and_empty_UGID_exists index 11a33baa3a..5b2e9128ed 100644 --- a/internal/users/testdata/golden/TestUpdateUser/GID_does_not_change_if_group_with_same_name_and_empty_UGID_exists +++ b/internal/users/testdata/golden/TestUpdateUser/GID_does_not_change_if_group_with_same_name_and_empty_UGID_exists @@ -17,4 +17,4 @@ users_to_groups: gid: 1111 - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestUpdateUser/Names_of_authd_groups_are_stored_in_lowercase b/internal/users/testdata/golden/TestUpdateUser/Names_of_authd_groups_are_stored_in_lowercase index 11a33baa3a..5b2e9128ed 100644 --- a/internal/users/testdata/golden/TestUpdateUser/Names_of_authd_groups_are_stored_in_lowercase +++ b/internal/users/testdata/golden/TestUpdateUser/Names_of_authd_groups_are_stored_in_lowercase @@ -17,4 +17,4 @@ users_to_groups: gid: 1111 - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestUpdateUser/Removing_last_user_from_a_group_keeps_the_group_record b/internal/users/testdata/golden/TestUpdateUser/Removing_last_user_from_a_group_keeps_the_group_record index f744472f5e..32929b27b5 100644 --- a/internal/users/testdata/golden/TestUpdateUser/Removing_last_user_from_a_group_keeps_the_group_record +++ b/internal/users/testdata/golden/TestUpdateUser/Removing_last_user_from_a_group_keeps_the_group_record @@ -15,4 +15,4 @@ groups: users_to_groups: - uid: 1111 gid: 1111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user b/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user index 11a33baa3a..5b2e9128ed 100644 --- a/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user +++ b/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user @@ -17,4 +17,4 @@ users_to_groups: gid: 1111 - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user_updating_local_groups b/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user_updating_local_groups index 11a33baa3a..5b2e9128ed 100644 --- a/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user_updating_local_groups +++ b/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user_updating_local_groups @@ -17,4 +17,4 @@ users_to_groups: gid: 1111 - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user_updating_local_groups_with_changes b/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user_updating_local_groups_with_changes index 11a33baa3a..5b2e9128ed 100644 --- a/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user_updating_local_groups_with_changes +++ b/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user_updating_local_groups_with_changes @@ -17,4 +17,4 @@ users_to_groups: gid: 1111 - uid: 1111 gid: 11111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user_with_different_capitalization b/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user_with_different_capitalization index 575dc64034..1f611d8210 100644 --- a/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user_with_different_capitalization +++ b/internal/users/testdata/golden/TestUpdateUser/Successfully_update_user_with_different_capitalization @@ -15,4 +15,4 @@ groups: users_to_groups: - uid: 1111 gid: 1111 -schema_version: 1 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestUpdateUser/UID_does_not_change_if_user_already_exists b/internal/users/testdata/golden/TestUpdateUser/UID_does_not_change_if_user_already_exists index f744472f5e..32929b27b5 100644 --- a/internal/users/testdata/golden/TestUpdateUser/UID_does_not_change_if_user_already_exists +++ b/internal/users/testdata/golden/TestUpdateUser/UID_does_not_change_if_user_already_exists @@ -15,4 +15,4 @@ groups: users_to_groups: - uid: 1111 gid: 1111 -schema_version: 1 +schema_version: 2 diff --git a/nss/integration-tests/integration_test.go b/nss/integration-tests/integration_test.go index 22a2ac62a3..d5a87bfa12 100644 --- a/nss/integration-tests/integration_test.go +++ b/nss/integration-tests/integration_test.go @@ -1,7 +1,6 @@ package nss_test import ( - "context" "log" "os" "path/filepath" @@ -29,19 +28,13 @@ func TestIntegration(t *testing.T) { defaultDbState := "multiple_users_and_groups" defaultGroupsFilePath := filepath.Join(filepath.Join("testdata", "empty.group")) - ctx, cancel := context.WithCancel(context.Background()) - _, stopped := testutils.RunDaemon(ctx, t, daemonPath, + testutils.StartDaemon(t, daemonPath, testutils.WithSocketPath(defaultSocket), testutils.WithPreviousDBState(defaultDbState), testutils.WithGroupFile(defaultGroupsFilePath), - testutils.WithEnvironment("AUTHD_INTEGRATIONTESTS_CURRENT_USER_AS_ROOT=1"), + testutils.WithCurrentUserAsRoot, ) - t.Cleanup(func() { - cancel() - <-stopped - }) - tests := map[string]struct { getentDB string key string @@ -113,16 +106,10 @@ func TestIntegration(t *testing.T) { if useAlternativeDaemon { // Run a specific new daemon for special test cases. - var daemonStopped chan struct{} - ctx, cancel := context.WithCancel(context.Background()) - socketPath, daemonStopped = testutils.RunDaemon(ctx, t, daemonPath, + socketPath = testutils.StartDaemon(t, daemonPath, testutils.WithPreviousDBState(tc.dbState), testutils.WithGroupFile(defaultGroupsFilePath), ) - t.Cleanup(func() { - cancel() - <-daemonStopped - }) } cmds := []string{tc.getentDB} @@ -167,13 +154,14 @@ func TestIntegration(t *testing.T) { } func TestMain(m *testing.M) { - execPath, cleanup, err := testutils.BuildDaemon("-tags=withexamplebroker,integrationtests") + var cleanup func() + var err error + daemonPath, cleanup, err = testutils.BuildDaemonWithExampleBroker() if err != nil { log.Printf("Setup: failed to build daemon: %v", err) os.Exit(1) } defer cleanup() - daemonPath = execPath m.Run() } diff --git a/pam/integration-tests/cli_test.go b/pam/integration-tests/cli_test.go index 7f5c10f321..8188847eef 100644 --- a/pam/integration-tests/cli_test.go +++ b/pam/integration-tests/cli_test.go @@ -260,11 +260,17 @@ func TestCLIAuthenticate(t *testing.T) { pidFile = filepath.Join(outDir, "authd.pid") - socketPath = runAuthd(t, !tc.currentUserNotRoot, + args := []testutils.DaemonOption{ testutils.WithGroupFile(groupFile), testutils.WithGroupFileOutput(groupFileOutput), testutils.WithPidFile(pidFile), - testutils.WithEnvironment(useOldDatabaseEnv(t, tc.oldDB)...)) + testutils.WithEnvironment(useOldDatabaseEnv(t, tc.oldDB)...), + } + if !tc.currentUserNotRoot { + args = append(args, testutils.WithCurrentUserAsRoot) + } + + socketPath = runAuthd(t, args...) } else { socketPath, groupFileOutput = sharedAuthd(t) } @@ -379,8 +385,7 @@ func TestCLIChangeAuthTok(t *testing.T) { if tc.currentUserNotRoot { // For the not-root tests authd has to run in a more restricted way. // In the other cases this is not needed, so we can just use a shared authd. - socketPath = runAuthd(t, false, - testutils.WithGroupFile(filepath.Join(t.TempDir(), "group"))) + socketPath = runAuthd(t, testutils.WithGroupFile(filepath.Join(t.TempDir(), "group"))) } else { socketPath, _ = sharedAuthd(t) } diff --git a/pam/integration-tests/exec_test.go b/pam/integration-tests/exec_test.go index 4e9ec28a1f..d9ed145a6c 100644 --- a/pam/integration-tests/exec_test.go +++ b/pam/integration-tests/exec_test.go @@ -973,19 +973,9 @@ func buildExecModuleWithCFlags(t *testing.T, cFlags []string, forPreload bool) s func buildExecClient(t *testing.T) string { t.Helper() - cmd := exec.Command("go", "build", "-C", "cmd/exec-client") - cmd.Dir = filepath.Join(testutils.CurrentDir()) - if testutils.CoverDirForTests() != "" { - // -cover is a "positional flag", so it needs to come right after the "build" command. - cmd.Args = append(cmd.Args, "-cover") - } - if testutils.IsAsan() { - // -asan is a "positional flag", so it needs to come right after the "build" command. - cmd.Args = append(cmd.Args, "-asan") - } - if testutils.IsRace() { - cmd.Args = append(cmd.Args, "-race") - } + cmd := exec.Command("go", "build") + cmd.Dir = filepath.Join(testutils.CurrentDir(), "cmd/exec-client") + cmd.Args = append(cmd.Args, testutils.GoBuildFlags()...) cmd.Args = append(cmd.Args, "-gcflags=all=-N -l") cmd.Args = append(cmd.Args, "-tags=pam_tests_exec_client") cmd.Env = append(os.Environ(), `CGO_CFLAGS=-O0 -g3`) diff --git a/pam/integration-tests/gdm_test.go b/pam/integration-tests/gdm_test.go index 0254107b36..a3755b03e2 100644 --- a/pam/integration-tests/gdm_test.go +++ b/pam/integration-tests/gdm_test.go @@ -787,7 +787,7 @@ func TestGdmModule(t *testing.T) { }, }, wantPamErrorMessages: []string{ - "can't select broker: error InvalidArgument from server: can't start authentication transaction: rpc error: code = InvalidArgument desc = no user name provided", + "error InvalidArgument from server: no user name provided", }, wantError: pam.ErrSystem, wantAcctMgmtErr: pam_test.ErrIgnore, diff --git a/pam/integration-tests/helpers_test.go b/pam/integration-tests/helpers_test.go index 2f22577f6b..11758a0992 100644 --- a/pam/integration-tests/helpers_test.go +++ b/pam/integration-tests/helpers_test.go @@ -50,17 +50,16 @@ var ( sharedAuthdInstance = authdInstance{} ) -func runAuthdForTesting(t *testing.T, currentUserAsRoot bool, isSharedDaemon bool, args ...testutils.DaemonOption) ( - socketPath string, waitFunc func()) { +func runAuthdForTesting(t *testing.T, isSharedDaemon bool, args ...testutils.DaemonOption) (socketPath string) { t.Helper() - ctx, cancel := context.WithCancel(context.Background()) + socketPath, cancelFunc := runAuthdForTestingWithCancel(t, isSharedDaemon, args...) + t.Cleanup(cancelFunc) + return socketPath +} - var env []string - if currentUserAsRoot { - env = append(env, authdCurrentUserRootEnvVariableContent) - } - args = append(args, testutils.WithEnvironment(env...)) +func runAuthdForTestingWithCancel(t *testing.T, isSharedDaemon bool, args ...testutils.DaemonOption) (socketPath string, cancelFunc func()) { + t.Helper() outputFile := filepath.Join(t.TempDir(), "authd.log") args = append(args, testutils.WithOutputFile(outputFile)) @@ -80,20 +79,15 @@ func runAuthdForTesting(t *testing.T, currentUserAsRoot bool, isSharedDaemon boo args = append(args, testutils.WithDBPath(filepath.Dir(database))) } - socketPath, stopped := testutils.RunDaemon(ctx, t, daemonPath, args...) + socketPath, cancelFunc = testutils.StartDaemonWithCancel(t, daemonPath, args...) saveArtifactsForDebugOnCleanup(t, []string{outputFile}) - return socketPath, func() { - cancel() - <-stopped - } + return socketPath, cancelFunc } -func runAuthd(t *testing.T, currentUserAsRoot bool, args ...testutils.DaemonOption) string { +func runAuthd(t *testing.T, args ...testutils.DaemonOption) string { t.Helper() - socketPath, waitFunc := runAuthdForTesting(t, currentUserAsRoot, false, args...) - t.Cleanup(waitFunc) - return socketPath + return runAuthdForTesting(t, false, args...) } func sharedAuthd(t *testing.T, args ...testutils.DaemonOption) (socketPath string, groupFile string) { @@ -109,9 +103,10 @@ func sharedAuthd(t *testing.T, args ...testutils.DaemonOption) (socketPath strin groups := filepath.Join(testutils.TestFamilyPath(t), "groups") args = append(args, testutils.WithGroupFile(groups), - testutils.WithGroupFileOutput(groupOutput)) - socket, cleanup := runAuthdForTesting(t, true, useSharedInstance, args...) - t.Cleanup(cleanup) + testutils.WithGroupFileOutput(groupOutput), + testutils.WithCurrentUserAsRoot, + ) + socket := runAuthdForTesting(t, useSharedInstance, args...) return socket, groupOutput } @@ -147,12 +142,15 @@ func sharedAuthd(t *testing.T, args ...testutils.DaemonOption) (socketPath strin return sa.socketPath, sa.groupsOutputPath } - args = append(slices.Clone(args), testutils.WithSharedDaemon(true)) sa.groupsFile = filepath.Join(testutils.TestFamilyPath(t), "groups") - args = append(args, testutils.WithGroupFile(sa.groupsFile)) sa.groupsOutputPath = filepath.Join(t.TempDir(), "groups") - args = append(args, testutils.WithGroupFileOutput(sa.groupsOutputPath)) - sa.socketPath, sa.cleanup = runAuthdForTesting(t, true, useSharedInstance, args...) + args = append(slices.Clone(args), + testutils.WithSharedDaemon(true), + testutils.WithCurrentUserAsRoot, + testutils.WithGroupFile(sa.groupsFile), + testutils.WithGroupFileOutput(sa.groupsOutputPath), + ) + sa.socketPath, sa.cleanup = runAuthdForTestingWithCancel(t, useSharedInstance, args...) return sa.socketPath, sa.groupsOutputPath } @@ -179,17 +177,7 @@ func preparePamRunnerTest(t *testing.T, clientPath string) []string { func buildPAMRunner(execPath string) (cleanup func(), err error) { cmd := exec.Command("go", "build") cmd.Dir = testutils.ProjectRoot() - if testutils.CoverDirForTests() != "" { - // -cover is a "positional flag", so it needs to come right after the "build" command. - cmd.Args = append(cmd.Args, "-cover") - } - if testutils.IsAsan() { - // -asan is a "positional flag", so it needs to come right after the "build" command. - cmd.Args = append(cmd.Args, "-asan") - } - if testutils.IsRace() { - cmd.Args = append(cmd.Args, "-race") - } + cmd.Args = append(cmd.Args, testutils.GoBuildFlags()...) cmd.Args = append(cmd.Args, "-gcflags=all=-N -l") cmd.Args = append(cmd.Args, "-tags=withpamrunner", "-o", filepath.Join(execPath, "pam_authd"), "./pam/tools/pam-runner") @@ -203,19 +191,9 @@ func buildPAMRunner(execPath string) (cleanup func(), err error) { func buildPAMExecChild(t *testing.T) string { t.Helper() - cmd := exec.Command("go", "build", "-C", "pam") - cmd.Dir = testutils.ProjectRoot() - if testutils.CoverDirForTests() != "" { - // -cover is a "positional flag", so it needs to come right after the "build" command. - cmd.Args = append(cmd.Args, "-cover") - } - if testutils.IsAsan() { - // -asan is a "positional flag", so it needs to come right after the "build" command. - cmd.Args = append(cmd.Args, "-asan") - } - if testutils.IsRace() { - cmd.Args = append(cmd.Args, "-race") - } + cmd := exec.Command("go", "build") + cmd.Dir = filepath.Join(testutils.ProjectRoot(), "pam") + cmd.Args = append(cmd.Args, testutils.GoBuildFlags()...) cmd.Args = append(cmd.Args, "-gcflags=all=-N -l") cmd.Args = append(cmd.Args, "-tags=pam_debug") cmd.Env = append(os.Environ(), `CGO_CFLAGS=-O0 -g3`) diff --git a/pam/integration-tests/integration_test.go b/pam/integration-tests/integration_test.go index 7b0fd25e92..930eb3bf1a 100644 --- a/pam/integration-tests/integration_test.go +++ b/pam/integration-tests/integration_test.go @@ -8,18 +8,17 @@ import ( "github.com/ubuntu/authd/internal/testutils" ) -const authdCurrentUserRootEnvVariableContent = "AUTHD_INTEGRATIONTESTS_CURRENT_USER_AS_ROOT=1" - var daemonPath string func TestMain(m *testing.M) { - execPath, daemonCleanup, err := testutils.BuildDaemon("-tags=withexamplebroker,integrationtests") + var cleanup func() + var err error + daemonPath, cleanup, err = testutils.BuildDaemonWithExampleBroker() if err != nil { - log.Printf("Setup: Failed to build authd daemon: %v", err) + log.Printf("Setup: failed to build daemon: %v", err) os.Exit(1) } - defer daemonCleanup() - daemonPath = execPath + defer cleanup() m.Run() } diff --git a/pam/integration-tests/native_test.go b/pam/integration-tests/native_test.go index 1c57232c91..1416bda358 100644 --- a/pam/integration-tests/native_test.go +++ b/pam/integration-tests/native_test.go @@ -426,11 +426,17 @@ func TestNativeAuthenticate(t *testing.T) { pidFile = filepath.Join(outDir, "authd.pid") - socketPath = runAuthd(t, !tc.currentUserNotRoot, + args := []testutils.DaemonOption{ testutils.WithGroupFile(groupFile), testutils.WithGroupFileOutput(groupFileOutput), testutils.WithPidFile(pidFile), - testutils.WithEnvironment(useOldDatabaseEnv(t, tc.oldDB)...)) + testutils.WithEnvironment(useOldDatabaseEnv(t, tc.oldDB)...), + } + if !tc.currentUserNotRoot { + args = append(args, testutils.WithCurrentUserAsRoot) + } + + socketPath = runAuthd(t, args...) } else { socketPath, groupFileOutput = sharedAuthd(t) } @@ -576,8 +582,7 @@ func TestNativeChangeAuthTok(t *testing.T) { if tc.currentUserNotRoot { // For the not-root tests authd has to run in a more restricted way. // In the other cases this is not needed, so we can just use a shared authd. - socketPath = runAuthd(t, false, - testutils.WithGroupFile(filepath.Join(t.TempDir(), "group"))) + socketPath = runAuthd(t, testutils.WithGroupFile(filepath.Join(t.TempDir(), "group"))) } else { socketPath, _ = sharedAuthd(t) } diff --git a/pam/integration-tests/ssh_test.go b/pam/integration-tests/ssh_test.go index 3329ddd3a2..ea9e66d0e3 100644 --- a/pam/integration-tests/ssh_test.go +++ b/pam/integration-tests/ssh_test.go @@ -154,6 +154,10 @@ func testSSHAuthenticate(t *testing.T, sharedSSHd bool) { sshEnvVariablesRegex = regexp.MustCompile(`(?m) (PATH|HOME|PWD|SSH_[A-Z]+)=.*(\n*)($[^ ]{2}.*)?$`) sshHostPortRegex = regexp.MustCompile(`([\d\.:]+) port ([\d:]+)`) + authctlPath, authctlCleanup, err := testutils.BuildAuthctl() + require.NoError(t, err) + t.Cleanup(authctlCleanup) + tests := map[string]struct { tape string tapeSettings []tapeSetting @@ -313,6 +317,10 @@ func testSSHAuthenticate(t *testing.T, sharedSSHd bool) { vhsCommandFinalAuthWaitVariable: `Wait /Password:/`, }, }, + "Authenticate_user_locks_and_unlocks_it": { + tape: "simple_auth_locks_unlocks", + daemonizeSSHd: true, + }, "Deny_authentication_if_max_attempts_reached": { tape: "max_attempts", @@ -408,7 +416,8 @@ Wait@%dms`, sshDefaultFinalWaitTimeout), authdEnv = append(authdEnv, useOldDatabaseEnv(t, tc.oldDB)...) - socketPath = runAuthd(t, true, + socketPath = runAuthd(t, + testutils.WithCurrentUserAsRoot, testutils.WithGroupFile(groupOutput), testutils.WithEnvironment(authdEnv...)) } else if !sharedSSHd { @@ -487,6 +496,8 @@ Wait@%dms`, sshDefaultFinalWaitTimeout), td.Command = tapeCommand td.Env[pam_test.RunnerEnvSupportsConversation] = "1" td.Env[pamSSHUserEnv] = user + td.Env["AUTHD_SOCKET"] = "unix://" + socketPath + td.Env["AUTHCTL_PATH"] = authctlPath td.Env["AUTHD_PAM_SSH_ARGS"] = strings.Join([]string{ "-p", sshdPort, "-F", os.DevNull, diff --git a/pam/integration-tests/testdata/golden/TestCLIAuthenticate/Deny_authentication_if_user_does_not_exist b/pam/integration-tests/testdata/golden/TestCLIAuthenticate/Deny_authentication_if_user_does_not_exist index 58714a3771..83ff5abf28 100644 --- a/pam/integration-tests/testdata/golden/TestCLIAuthenticate/Deny_authentication_if_user_does_not_exist +++ b/pam/integration-tests/testdata/golden/TestCLIAuthenticate/Deny_authentication_if_user_does_not_exist @@ -18,7 +18,7 @@ Username: user-unexistent 1. local > 2. ExampleBroker -PAM Error Message: can't select broker: user "user-unexistent" does not exist +PAM Error Message: user "user-unexistent" does not exist PAM Authenticate() User: "user-unexistent" Result: error: PAM exit code: 4 diff --git a/pam/integration-tests/testdata/golden/TestCLIChangeAuthTok/Prevent_change_password_if_user_does_not_exist b/pam/integration-tests/testdata/golden/TestCLIChangeAuthTok/Prevent_change_password_if_user_does_not_exist index 725d73f473..4a38d4a8d0 100644 --- a/pam/integration-tests/testdata/golden/TestCLIChangeAuthTok/Prevent_change_password_if_user_does_not_exist +++ b/pam/integration-tests/testdata/golden/TestCLIChangeAuthTok/Prevent_change_password_if_user_does_not_exist @@ -18,7 +18,7 @@ Username: user-unexistent 1. local > 2. ExampleBroker -PAM Error Message: can't select broker: user "user-unexistent" does not exist +PAM Error Message: user "user-unexistent" does not exist PAM ChangeAuthTok() User: "user-unexistent" Result: error: PAM exit code: 4 diff --git a/pam/integration-tests/testdata/golden/TestNativeAuthenticate/Deny_authentication_if_user_does_not_exist b/pam/integration-tests/testdata/golden/TestNativeAuthenticate/Deny_authentication_if_user_does_not_exist index b7ce250a7f..c7d2b094a2 100644 --- a/pam/integration-tests/testdata/golden/TestNativeAuthenticate/Deny_authentication_if_user_does_not_exist +++ b/pam/integration-tests/testdata/golden/TestNativeAuthenticate/Deny_authentication_if_user_does_not_exist @@ -18,7 +18,7 @@ Choose your provider: 2. ExampleBroker Choose your provider: > 2 -PAM Error Message: can't select broker: user "user-unexistent" does not exist +PAM Error Message: user "user-unexistent" does not exist PAM Authenticate() User: "user-unexistent" Result: error: PAM exit code: 4 diff --git a/pam/integration-tests/testdata/golden/TestNativeChangeAuthTok/Prevent_change_password_if_user_does_not_exist b/pam/integration-tests/testdata/golden/TestNativeChangeAuthTok/Prevent_change_password_if_user_does_not_exist index b7c9d98836..7c7e24b4ad 100644 --- a/pam/integration-tests/testdata/golden/TestNativeChangeAuthTok/Prevent_change_password_if_user_does_not_exist +++ b/pam/integration-tests/testdata/golden/TestNativeChangeAuthTok/Prevent_change_password_if_user_does_not_exist @@ -27,7 +27,7 @@ Username: user-unexistent Or enter 'r' to go back to user selection Choose your provider: > 2 -PAM Error Message: can't select broker: user "user-unexistent" does not exist +PAM Error Message: user "user-unexistent" does not exist PAM ChangeAuthTok() User: "user-unexistent" Result: error: PAM exit code: 4 diff --git a/pam/integration-tests/testdata/golden/TestSSHAuthenticate/Authenticate_user_locks_and_unlocks_it b/pam/integration-tests/testdata/golden/TestSSHAuthenticate/Authenticate_user_locks_and_unlocks_it new file mode 100644 index 0000000000..87139b9940 --- /dev/null +++ b/pam/integration-tests/testdata/golden/TestSSHAuthenticate/Authenticate_user_locks_and_unlocks_it @@ -0,0 +1,138 @@ +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Provider selection == + 1. local + 2. ExampleBroker +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it@localhost) Choose your provider: +> +──────────────────────────────────────────────────────────────────────────────── +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Provider selection == + 1. local + 2. ExampleBroker +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it@localhost) Choose your provider: +> 2 +──────────────────────────────────────────────────────────────────────────────── +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Provider selection == + 1. local + 2. ExampleBroker +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it@localhost) Choose your provider: +> 2 +== Password authentication == +Enter 'r' to cancel the request and go back to select the authentication method +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it@localhost) Gimme your password: +> +──────────────────────────────────────────────────────────────────────────────── +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Provider selection == + 1. local + 2. ExampleBroker +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it@localhost) Choose your provider: +> 2 +== Password authentication == +Enter 'r' to cancel the request and go back to select the authentication method +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it@localhost) Gimme your password: +> +PAM Authenticate() finished for user 'user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it' +PAM AcctMgmt() finished for user 'user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it' + SSHD: Connected to ssh via authd module! [TestSSHAuthenticate/Authenticate_user_locks_and_unlocks_it] + HOME=${AUTHD_TEST_HOME} + LOGNAME=user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it + PATH=${AUTHD_TEST_PATH} + PWD=${AUTHD_TEST_PWD} + SHELL=/bin/sh + SSH_CLIENT=${AUTHD_TEST_SSH_CLIENT} + SSH_CONNECTION=${AUTHD_TEST_SSH_CONNECTION} + SSH_TTY=${AUTHD_TEST_SSH_TTY} + TERM=xterm-256color + USER=user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it +Connection to localhost closed. +> +──────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────── +> ${AUTHCTL_PATH} --help +authctl is a command-line tool to interact with the authd service for user and group management. + +Usage: + authctl [flags] + authctl [command] + +Available Commands: + user Commands related to users + help Help about any command + +Flags: + -h, --help help for authctl + +Use "authctl [command] --help" for more information about a command. +> +──────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────── +> echo $? +0 +> +──────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────── +> ${AUTHCTL_PATH} user lock ${AUTHD_PAM_SSH_USER} +> +──────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────── +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Password authentication == +Enter 'r' to cancel the request and go back to select the authentication method +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it@localhost) Gimme your password: +> +──────────────────────────────────────────────────────────────────────────────── +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Password authentication == +Enter 'r' to cancel the request and go back to select the authentication method +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it@localhost) Gimme your password: +> +permission denied: user user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it is locked +Received disconnect from ${SSH_HOST} port ${SSH_PORT} Too many authentication failures +Disconnected from ${SSH_HOST} port ${SSH_PORT} +> +──────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────── +> ${AUTHCTL_PATH} user lock ${AUTHD_PAM_SSH_USER} +> +──────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────── +> ${AUTHCTL_PATH} user unlock ${AUTHD_PAM_SSH_USER} +> +──────────────────────────────────────────────────────────────────────────────── +> ${AUTHCTL_PATH} user unlock ${AUTHD_PAM_SSH_USER} +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Password authentication == +Enter 'r' to cancel the request and go back to select the authentication method +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it@localhost) Gimme your password: +> +──────────────────────────────────────────────────────────────────────────────── +> ${AUTHCTL_PATH} user unlock ${AUTHD_PAM_SSH_USER} +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Password authentication == +Enter 'r' to cancel the request and go back to select the authentication method +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it@localhost) Gimme your password: +> +PAM Authenticate() finished for user 'user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it' +PAM AcctMgmt() finished for user 'user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it' + SSHD: Connected to ssh via authd module! [TestSSHAuthenticate/Authenticate_user_locks_and_unlocks_it] + HOME=${AUTHD_TEST_HOME} + LOGNAME=user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it + PATH=${AUTHD_TEST_PATH} + PWD=${AUTHD_TEST_PWD} + SHELL=/bin/sh + SSH_CLIENT=${AUTHD_TEST_SSH_CLIENT} + SSH_CONNECTION=${AUTHD_TEST_SSH_CONNECTION} + SSH_TTY=${AUTHD_TEST_SSH_TTY} + TERM=xterm-256color + USER=user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it +Connection to localhost closed. +> +──────────────────────────────────────────────────────────────────────────────── diff --git a/pam/integration-tests/testdata/golden/TestSSHAuthenticate/Authenticate_user_locks_and_unlocks_it_on_shared_SSHd b/pam/integration-tests/testdata/golden/TestSSHAuthenticate/Authenticate_user_locks_and_unlocks_it_on_shared_SSHd new file mode 100644 index 0000000000..c1927d02ea --- /dev/null +++ b/pam/integration-tests/testdata/golden/TestSSHAuthenticate/Authenticate_user_locks_and_unlocks_it_on_shared_SSHd @@ -0,0 +1,138 @@ +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Provider selection == + 1. local + 2. ExampleBroker +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd@localhost) Choose your provider: +> +──────────────────────────────────────────────────────────────────────────────── +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Provider selection == + 1. local + 2. ExampleBroker +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd@localhost) Choose your provider: +> 2 +──────────────────────────────────────────────────────────────────────────────── +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Provider selection == + 1. local + 2. ExampleBroker +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd@localhost) Choose your provider: +> 2 +== Password authentication == +Enter 'r' to cancel the request and go back to select the authentication method +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd@localhost) Gimme your password: +> +──────────────────────────────────────────────────────────────────────────────── +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Provider selection == + 1. local + 2. ExampleBroker +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd@localhost) Choose your provider: +> 2 +== Password authentication == +Enter 'r' to cancel the request and go back to select the authentication method +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd@localhost) Gimme your password: +> +PAM Authenticate() finished for user 'user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd' +PAM AcctMgmt() finished for user 'user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd' + SSHD: Connected to ssh via authd module! [TestSSHAuthenticate] + HOME=${AUTHD_TEST_HOME} + LOGNAME=user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd + PATH=${AUTHD_TEST_PATH} + PWD=${AUTHD_TEST_PWD} + SHELL=/bin/sh + SSH_CLIENT=${AUTHD_TEST_SSH_CLIENT} + SSH_CONNECTION=${AUTHD_TEST_SSH_CONNECTION} + SSH_TTY=${AUTHD_TEST_SSH_TTY} + TERM=xterm-256color + USER=user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd +Connection to localhost closed. +> +──────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────── +> ${AUTHCTL_PATH} --help +authctl is a command-line tool to interact with the authd service for user and group management. + +Usage: + authctl [flags] + authctl [command] + +Available Commands: + user Commands related to users + help Help about any command + +Flags: + -h, --help help for authctl + +Use "authctl [command] --help" for more information about a command. +> +──────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────── +> echo $? +0 +> +──────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────── +> ${AUTHCTL_PATH} user lock ${AUTHD_PAM_SSH_USER} +> +──────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────── +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Password authentication == +Enter 'r' to cancel the request and go back to select the authentication method +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd@localhost) Gimme your password: +> +──────────────────────────────────────────────────────────────────────────────── +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Password authentication == +Enter 'r' to cancel the request and go back to select the authentication method +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd@localhost) Gimme your password: +> +permission denied: user user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd is locked +Received disconnect from ${SSH_HOST} port ${SSH_PORT} Too many authentication failures +Disconnected from ${SSH_HOST} port ${SSH_PORT} +> +──────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────── +> ${AUTHCTL_PATH} user lock ${AUTHD_PAM_SSH_USER} +> +──────────────────────────────────────────────────────────────────────────────── +> +──────────────────────────────────────────────────────────────────────────────── +> ${AUTHCTL_PATH} user unlock ${AUTHD_PAM_SSH_USER} +> +──────────────────────────────────────────────────────────────────────────────── +> ${AUTHCTL_PATH} user unlock ${AUTHD_PAM_SSH_USER} +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Password authentication == +Enter 'r' to cancel the request and go back to select the authentication method +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd@localhost) Gimme your password: +> +──────────────────────────────────────────────────────────────────────────────── +> ${AUTHCTL_PATH} user unlock ${AUTHD_PAM_SSH_USER} +> ssh ${AUTHD_PAM_SSH_USER}@localhost ${AUTHD_PAM_SSH_ARGS} +== Password authentication == +Enter 'r' to cancel the request and go back to select the authentication method +(user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd@localhost) Gimme your password: +> +PAM Authenticate() finished for user 'user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd' +PAM AcctMgmt() finished for user 'user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd' + SSHD: Connected to ssh via authd module! [TestSSHAuthenticate] + HOME=${AUTHD_TEST_HOME} + LOGNAME=user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd + PATH=${AUTHD_TEST_PATH} + PWD=${AUTHD_TEST_PWD} + SHELL=/bin/sh + SSH_CLIENT=${AUTHD_TEST_SSH_CLIENT} + SSH_CONNECTION=${AUTHD_TEST_SSH_CONNECTION} + SSH_TTY=${AUTHD_TEST_SSH_TTY} + TERM=xterm-256color + USER=user-integration-pre-check-ssh-authenticate-user-locks-and-unlocks-it-on-shared-sshd +Connection to localhost closed. +> +──────────────────────────────────────────────────────────────────────────────── diff --git a/pam/integration-tests/testdata/tapes/ssh/simple_auth_locks_unlocks.tape b/pam/integration-tests/testdata/tapes/ssh/simple_auth_locks_unlocks.tape new file mode 100644 index 0000000000..c736c096f2 --- /dev/null +++ b/pam/integration-tests/testdata/tapes/ssh/simple_auth_locks_unlocks.tape @@ -0,0 +1,89 @@ +Hide +TypeInPrompt+Shell "${AUTHD_TEST_TAPE_COMMAND}" +Enter +Wait+Prompt /Choose your provider/ +Show + +Hide +TypeInPrompt "2" +Show + +Hide +Enter +Wait+Prompt /Gimme your password/ +Show + +Hide +Type "goodpass" +Enter +${AUTHD_TEST_TAPE_COMMAND_AUTH_FINAL_WAIT} +Show + +ClearTerminal + +Hide +TypeInPrompt+Shell "${AUTHCTL_PATH} --help" +Enter +Wait +Show + +ClearTerminal + +Hide +TypeInPrompt+Shell "echo $?" +Enter +Wait +Show + +ClearTerminal + +Hide +TypeInPrompt+Shell "${AUTHCTL_PATH} user lock ${AUTHD_PAM_SSH_USER}" +Enter +Wait +Show + +ClearTerminal + +Hide +TypeInPrompt+Shell "${AUTHD_TEST_TAPE_COMMAND}" +Enter +Wait+Prompt /Gimme your password/ +Show + +Hide +Type "goodpass" +Enter +Wait /permission denied: user .* is locked/ +Wait +Show + +ClearTerminal + +# lock again... + +Hide +TypeInPrompt+Shell "${AUTHCTL_PATH} user lock ${AUTHD_PAM_SSH_USER}" +Enter +Wait +Show + +ClearTerminal + +Hide +TypeInPrompt+Shell "${AUTHCTL_PATH} user unlock ${AUTHD_PAM_SSH_USER}" +Enter +Wait +Show + +Hide +TypeInPrompt+Shell "${AUTHD_TEST_TAPE_COMMAND}" +Enter +Wait+Prompt /Gimme your password/ +Show + +Hide +Type "goodpass" +Enter +${AUTHD_TEST_TAPE_COMMAND_AUTH_FINAL_WAIT} +Show diff --git a/pam/internal/adapter/authentication.go b/pam/internal/adapter/authentication.go index 23862af334..a21bf68d5f 100644 --- a/pam/internal/adapter/authentication.go +++ b/pam/internal/adapter/authentication.go @@ -68,7 +68,7 @@ func sendIsAuthenticated(ctx context.Context, client authd.PAMClient, sessionID } return pamError{ status: pam.ErrSystem, - msg: fmt.Sprintf("authentication status failure: %v", err), + msg: err.Error(), } } diff --git a/pam/internal/adapter/commands.go b/pam/internal/adapter/commands.go index 0c8b798096..c0dfe15567 100644 --- a/pam/internal/adapter/commands.go +++ b/pam/internal/adapter/commands.go @@ -47,7 +47,7 @@ func startBrokerSession(client authd.PAMClient, brokerID, username string, mode sbResp, err := client.SelectBroker(context.TODO(), sbReq) if err != nil { - return pamError{status: pam.ErrSystem, msg: fmt.Sprintf("can't select broker: %v", err)} + return pamError{status: pam.ErrSystem, msg: err.Error()} } sessionID := sbResp.GetSessionId() diff --git a/pam/internal/adapter/gdmmodel_test.go b/pam/internal/adapter/gdmmodel_test.go index 0e459bac15..4c6d166929 100644 --- a/pam/internal/adapter/gdmmodel_test.go +++ b/pam/internal/adapter/gdmmodel_test.go @@ -1776,7 +1776,7 @@ func TestGdmModel(t *testing.T) { }, wantExitStatus: pamError{ status: pam.ErrSystem, - msg: "can't select broker: error during broker selection", + msg: "error during broker selection", }, }, "Error_during_broker_selection_if_session_ID_is_empty": { @@ -2028,7 +2028,7 @@ func TestGdmModel(t *testing.T) { wantStage: proto.Stage_challenge, wantExitStatus: pamError{ status: pam.ErrSystem, - msg: "authentication status failure: some authentication error", + msg: "some authentication error", }, }, "Error_on_authentication_client_invalid_message": { diff --git a/shell-completion/bash/authctl b/shell-completion/bash/authctl new file mode 100644 index 0000000000..3a97824dbe --- /dev/null +++ b/shell-completion/bash/authctl @@ -0,0 +1,426 @@ +# bash completion V2 for authctl -*- shell-script -*- + +__authctl_debug() +{ + if [[ -n ${BASH_COMP_DEBUG_FILE-} ]]; then + echo "$*" >> "${BASH_COMP_DEBUG_FILE}" + fi +} + +# Macs have bash3 for which the bash-completion package doesn't include +# _init_completion. This is a minimal version of that function. +__authctl_init_completion() +{ + COMPREPLY=() + _get_comp_words_by_ref "$@" cur prev words cword +} + +# This function calls the authctl program to obtain the completion +# results and the directive. It fills the 'out' and 'directive' vars. +__authctl_get_completion_results() { + local requestComp lastParam lastChar args + + # Prepare the command to request completions for the program. + # Calling ${words[0]} instead of directly authctl allows handling aliases + args=("${words[@]:1}") + requestComp="${words[0]} __complete ${args[*]}" + + lastParam=${words[$((${#words[@]}-1))]} + lastChar=${lastParam:$((${#lastParam}-1)):1} + __authctl_debug "lastParam ${lastParam}, lastChar ${lastChar}" + + if [[ -z ${cur} && ${lastChar} != = ]]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go method. + __authctl_debug "Adding extra empty parameter" + requestComp="${requestComp} ''" + fi + + # When completing a flag with an = (e.g., authctl -n=) + # bash focuses on the part after the =, so we need to remove + # the flag part from $cur + if [[ ${cur} == -*=* ]]; then + cur="${cur#*=}" + fi + + __authctl_debug "Calling ${requestComp}" + # Use eval to handle any environment variables and such + out=$(eval "${requestComp}" 2>/dev/null) + + # Extract the directive integer at the very end of the output following a colon (:) + directive=${out##*:} + # Remove the directive + out=${out%:*} + if [[ ${directive} == "${out}" ]]; then + # There is not directive specified + directive=0 + fi + __authctl_debug "The completion directive is: ${directive}" + __authctl_debug "The completions are: ${out}" +} + +__authctl_process_completion_results() { + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 + + if (((directive & shellCompDirectiveError) != 0)); then + # Error code. No completion. + __authctl_debug "Received error from custom completion go code" + return + else + if (((directive & shellCompDirectiveNoSpace) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __authctl_debug "Activating no space" + compopt -o nospace + else + __authctl_debug "No space directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveKeepOrder) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + # no sort isn't supported for bash less than < 4.4 + if [[ ${BASH_VERSINFO[0]} -lt 4 || ( ${BASH_VERSINFO[0]} -eq 4 && ${BASH_VERSINFO[1]} -lt 4 ) ]]; then + __authctl_debug "No sort directive not supported in this version of bash" + else + __authctl_debug "Activating keep order" + compopt -o nosort + fi + else + __authctl_debug "No sort directive not supported in this version of bash" + fi + fi + if (((directive & shellCompDirectiveNoFileComp) != 0)); then + if [[ $(type -t compopt) == builtin ]]; then + __authctl_debug "Activating no file completion" + compopt +o default + else + __authctl_debug "No file completion directive not supported in this version of bash" + fi + fi + fi + + # Separate activeHelp from normal completions + local completions=() + local activeHelp=() + __authctl_extract_activeHelp + + if (((directive & shellCompDirectiveFilterFileExt) != 0)); then + # File extension filtering + local fullFilter="" filter filteringCmd + + # Do not use quotes around the $completions variable or else newline + # characters will be kept. + for filter in ${completions[*]}; do + fullFilter+="$filter|" + done + + filteringCmd="_filedir $fullFilter" + __authctl_debug "File filtering command: $filteringCmd" + $filteringCmd + elif (((directive & shellCompDirectiveFilterDirs) != 0)); then + # File completion for directories only + + local subdir + subdir=${completions[0]} + if [[ -n $subdir ]]; then + __authctl_debug "Listing directories in $subdir" + pushd "$subdir" >/dev/null 2>&1 && _filedir -d && popd >/dev/null 2>&1 || return + else + __authctl_debug "Listing directories in ." + _filedir -d + fi + else + __authctl_handle_completion_types + fi + + __authctl_handle_special_char "$cur" : + __authctl_handle_special_char "$cur" = + + # Print the activeHelp statements before we finish + __authctl_handle_activeHelp +} + +__authctl_handle_activeHelp() { + # Print the activeHelp statements + if ((${#activeHelp[*]} != 0)); then + if [ -z $COMP_TYPE ]; then + # Bash v3 does not set the COMP_TYPE variable. + printf "\n"; + printf "%s\n" "${activeHelp[@]}" + printf "\n" + __authctl_reprint_commandLine + return + fi + + # Only print ActiveHelp on the second TAB press + if [ $COMP_TYPE -eq 63 ]; then + printf "\n" + printf "%s\n" "${activeHelp[@]}" + + if ((${#COMPREPLY[*]} == 0)); then + # When there are no completion choices from the program, file completion + # may kick in if the program has not disabled it; in such a case, we want + # to know if any files will match what the user typed, so that we know if + # there will be completions presented, so that we know how to handle ActiveHelp. + # To find out, we actually trigger the file completion ourselves; + # the call to _filedir will fill COMPREPLY if files match. + if (((directive & shellCompDirectiveNoFileComp) == 0)); then + __authctl_debug "Listing files" + _filedir + fi + fi + + if ((${#COMPREPLY[*]} != 0)); then + # If there are completion choices to be shown, print a delimiter. + # Re-printing the command-line will automatically be done + # by the shell when it prints the completion choices. + printf -- "--" + else + # When there are no completion choices at all, we need + # to re-print the command-line since the shell will + # not be doing it itself. + __authctl_reprint_commandLine + fi + elif [ $COMP_TYPE -eq 37 ] || [ $COMP_TYPE -eq 42 ]; then + # For completion type: menu-complete/menu-complete-backward and insert-completions + # the completions are immediately inserted into the command-line, so we first + # print the activeHelp message and reprint the command-line since the shell won't. + printf "\n" + printf "%s\n" "${activeHelp[@]}" + + __authctl_reprint_commandLine + fi + fi +} + +__authctl_reprint_commandLine() { + # The prompt format is only available from bash 4.4. + # We test if it is available before using it. + if (x=${PS1@P}) 2> /dev/null; then + printf "%s" "${PS1@P}${COMP_LINE[@]}" + else + # Can't print the prompt. Just print the + # text the user had typed, it is workable enough. + printf "%s" "${COMP_LINE[@]}" + fi +} + +# Separate activeHelp lines from real completions. +# Fills the $activeHelp and $completions arrays. +__authctl_extract_activeHelp() { + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + + while IFS='' read -r comp; do + [[ -z $comp ]] && continue + + if [[ ${comp:0:endIndex} == $activeHelpMarker ]]; then + comp=${comp:endIndex} + __authctl_debug "ActiveHelp found: $comp" + if [[ -n $comp ]]; then + activeHelp+=("$comp") + fi + else + # Not an activeHelp line but a normal completion + completions+=("$comp") + fi + done <<<"${out}" +} + +__authctl_handle_completion_types() { + __authctl_debug "__authctl_handle_completion_types: COMP_TYPE is $COMP_TYPE" + + case $COMP_TYPE in + 37|42) + # Type: menu-complete/menu-complete-backward and insert-completions + # If the user requested inserting one completion at a time, or all + # completions at once on the command-line we must remove the descriptions. + # https://github.com/spf13/cobra/issues/1508 + + # If there are no completions, we don't need to do anything + (( ${#completions[@]} == 0 )) && return 0 + + local tab=$'\t' + + # Strip any description and escape the completion to handled special characters + IFS=$'\n' read -ra completions -d '' < <(printf "%q\n" "${completions[@]%%$tab*}") + + # Only consider the completions that match + IFS=$'\n' read -ra COMPREPLY -d '' < <(IFS=$'\n'; compgen -W "${completions[*]}" -- "${cur}") + + # compgen looses the escaping so we need to escape all completions again since they will + # all be inserted on the command-line. + IFS=$'\n' read -ra COMPREPLY -d '' < <(printf "%q\n" "${COMPREPLY[@]}") + ;; + + *) + # Type: complete (normal completion) + __authctl_handle_standard_completion_case + ;; + esac +} + +__authctl_handle_standard_completion_case() { + local tab=$'\t' + + # If there are no completions, we don't need to do anything + (( ${#completions[@]} == 0 )) && return 0 + + # Short circuit to optimize if we don't have descriptions + if [[ "${completions[*]}" != *$tab* ]]; then + # First, escape the completions to handle special characters + IFS=$'\n' read -ra completions -d '' < <(printf "%q\n" "${completions[@]}") + # Only consider the completions that match what the user typed + IFS=$'\n' read -ra COMPREPLY -d '' < <(IFS=$'\n'; compgen -W "${completions[*]}" -- "${cur}") + + # compgen looses the escaping so, if there is only a single completion, we need to + # escape it again because it will be inserted on the command-line. If there are multiple + # completions, we don't want to escape them because they will be printed in a list + # and we don't want to show escape characters in that list. + if (( ${#COMPREPLY[@]} == 1 )); then + COMPREPLY[0]=$(printf "%q" "${COMPREPLY[0]}") + fi + return 0 + fi + + local longest=0 + local compline + # Look for the longest completion so that we can format things nicely + while IFS='' read -r compline; do + [[ -z $compline ]] && continue + + # Before checking if the completion matches what the user typed, + # we need to strip any description and escape the completion to handle special + # characters because those escape characters are part of what the user typed. + # Don't call "printf" in a sub-shell because it will be much slower + # since we are in a loop. + printf -v comp "%q" "${compline%%$tab*}" &>/dev/null || comp=$(printf "%q" "${compline%%$tab*}") + + # Only consider the completions that match + [[ $comp == "$cur"* ]] || continue + + # The completions matches. Add it to the list of full completions including + # its description. We don't escape the completion because it may get printed + # in a list if there are more than one and we don't want show escape characters + # in that list. + COMPREPLY+=("$compline") + + # Strip any description before checking the length, and again, don't escape + # the completion because this length is only used when printing the completions + # in a list and we don't want show escape characters in that list. + comp=${compline%%$tab*} + if ((${#comp}>longest)); then + longest=${#comp} + fi + done < <(printf "%s\n" "${completions[@]}") + + # If there is a single completion left, remove the description text and escape any special characters + if ((${#COMPREPLY[*]} == 1)); then + __authctl_debug "COMPREPLY[0]: ${COMPREPLY[0]}" + COMPREPLY[0]=$(printf "%q" "${COMPREPLY[0]%%$tab*}") + __authctl_debug "Removed description from single completion, which is now: ${COMPREPLY[0]}" + else + # Format the descriptions + __authctl_format_comp_descriptions $longest + fi +} + +__authctl_handle_special_char() +{ + local comp="$1" + local char=$2 + if [[ "$comp" == *${char}* && "$COMP_WORDBREAKS" == *${char}* ]]; then + local word=${comp%"${comp##*${char}}"} + local idx=${#COMPREPLY[*]} + while ((--idx >= 0)); do + COMPREPLY[idx]=${COMPREPLY[idx]#"$word"} + done + fi +} + +__authctl_format_comp_descriptions() +{ + local tab=$'\t' + local comp desc maxdesclength + local longest=$1 + + local i ci + for ci in ${!COMPREPLY[*]}; do + comp=${COMPREPLY[ci]} + # Properly format the description string which follows a tab character if there is one + if [[ "$comp" == *$tab* ]]; then + __authctl_debug "Original comp: $comp" + desc=${comp#*$tab} + comp=${comp%%$tab*} + + # $COLUMNS stores the current shell width. + # Remove an extra 4 because we add 2 spaces and 2 parentheses. + maxdesclength=$(( COLUMNS - longest - 4 )) + + # Make sure we can fit a description of at least 8 characters + # if we are to align the descriptions. + if ((maxdesclength > 8)); then + # Add the proper number of spaces to align the descriptions + for ((i = ${#comp} ; i < longest ; i++)); do + comp+=" " + done + else + # Don't pad the descriptions so we can fit more text after the completion + maxdesclength=$(( COLUMNS - ${#comp} - 4 )) + fi + + # If there is enough space for any description text, + # truncate the descriptions that are too long for the shell width + if ((maxdesclength > 0)); then + if ((${#desc} > maxdesclength)); then + desc=${desc:0:$(( maxdesclength - 1 ))} + desc+="…" + fi + comp+=" ($desc)" + fi + COMPREPLY[ci]=$comp + __authctl_debug "Final comp: $comp" + fi + done +} + +__start_authctl() +{ + local cur prev words cword split + + COMPREPLY=() + + # Call _init_completion from the bash-completion package + # to prepare the arguments properly + if declare -F _init_completion >/dev/null 2>&1; then + _init_completion -n =: || return + else + __authctl_init_completion -n =: || return + fi + + __authctl_debug + __authctl_debug "========= starting completion logic ==========" + __authctl_debug "cur is ${cur}, words[*] is ${words[*]}, #words[@] is ${#words[@]}, cword is $cword" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $cword location, so we need + # to truncate the command-line ($words) up to the $cword location. + words=("${words[@]:0:$cword+1}") + __authctl_debug "Truncated words[*]: ${words[*]}," + + local out directive + __authctl_get_completion_results + __authctl_process_completion_results +} + +if [[ $(type -t compopt) = "builtin" ]]; then + complete -o default -F __start_authctl authctl +else + complete -o default -o nospace -F __start_authctl authctl +fi + +# ex: ts=4 sw=4 et filetype=sh diff --git a/shell-completion/fish/authctl.fish b/shell-completion/fish/authctl.fish new file mode 100644 index 0000000000..a59163359d --- /dev/null +++ b/shell-completion/fish/authctl.fish @@ -0,0 +1,235 @@ +# fish completion for authctl -*- shell-script -*- + +function __authctl_debug + set -l file "$BASH_COMP_DEBUG_FILE" + if test -n "$file" + echo "$argv" >> $file + end +end + +function __authctl_perform_completion + __authctl_debug "Starting __authctl_perform_completion" + + # Extract all args except the last one + set -l args (commandline -opc) + # Extract the last arg and escape it in case it is a space + set -l lastArg (string escape -- (commandline -ct)) + + __authctl_debug "args: $args" + __authctl_debug "last arg: $lastArg" + + # Disable ActiveHelp which is not supported for fish shell + set -l requestComp "AUTHCTL_ACTIVE_HELP=0 $args[1] __complete $args[2..-1] $lastArg" + + __authctl_debug "Calling $requestComp" + set -l results (eval $requestComp 2> /dev/null) + + # Some programs may output extra empty lines after the directive. + # Let's ignore them or else it will break completion. + # Ref: https://github.com/spf13/cobra/issues/1279 + for line in $results[-1..1] + if test (string trim -- $line) = "" + # Found an empty line, remove it + set results $results[1..-2] + else + # Found non-empty line, we have our proper output + break + end + end + + set -l comps $results[1..-2] + set -l directiveLine $results[-1] + + # For Fish, when completing a flag with an = (e.g., -n=) + # completions must be prefixed with the flag + set -l flagPrefix (string match -r -- '-.*=' "$lastArg") + + __authctl_debug "Comps: $comps" + __authctl_debug "DirectiveLine: $directiveLine" + __authctl_debug "flagPrefix: $flagPrefix" + + for comp in $comps + printf "%s%s\n" "$flagPrefix" "$comp" + end + + printf "%s\n" "$directiveLine" +end + +# this function limits calls to __authctl_perform_completion, by caching the result behind $__authctl_perform_completion_once_result +function __authctl_perform_completion_once + __authctl_debug "Starting __authctl_perform_completion_once" + + if test -n "$__authctl_perform_completion_once_result" + __authctl_debug "Seems like a valid result already exists, skipping __authctl_perform_completion" + return 0 + end + + set --global __authctl_perform_completion_once_result (__authctl_perform_completion) + if test -z "$__authctl_perform_completion_once_result" + __authctl_debug "No completions, probably due to a failure" + return 1 + end + + __authctl_debug "Performed completions and set __authctl_perform_completion_once_result" + return 0 +end + +# this function is used to clear the $__authctl_perform_completion_once_result variable after completions are run +function __authctl_clear_perform_completion_once_result + __authctl_debug "" + __authctl_debug "========= clearing previously set __authctl_perform_completion_once_result variable ==========" + set --erase __authctl_perform_completion_once_result + __authctl_debug "Successfully erased the variable __authctl_perform_completion_once_result" +end + +function __authctl_requires_order_preservation + __authctl_debug "" + __authctl_debug "========= checking if order preservation is required ==========" + + __authctl_perform_completion_once + if test -z "$__authctl_perform_completion_once_result" + __authctl_debug "Error determining if order preservation is required" + return 1 + end + + set -l directive (string sub --start 2 $__authctl_perform_completion_once_result[-1]) + __authctl_debug "Directive is: $directive" + + set -l shellCompDirectiveKeepOrder 32 + set -l keeporder (math (math --scale 0 $directive / $shellCompDirectiveKeepOrder) % 2) + __authctl_debug "Keeporder is: $keeporder" + + if test $keeporder -ne 0 + __authctl_debug "This does require order preservation" + return 0 + end + + __authctl_debug "This doesn't require order preservation" + return 1 +end + + +# This function does two things: +# - Obtain the completions and store them in the global __authctl_comp_results +# - Return false if file completion should be performed +function __authctl_prepare_completions + __authctl_debug "" + __authctl_debug "========= starting completion logic ==========" + + # Start fresh + set --erase __authctl_comp_results + + __authctl_perform_completion_once + __authctl_debug "Completion results: $__authctl_perform_completion_once_result" + + if test -z "$__authctl_perform_completion_once_result" + __authctl_debug "No completion, probably due to a failure" + # Might as well do file completion, in case it helps + return 1 + end + + set -l directive (string sub --start 2 $__authctl_perform_completion_once_result[-1]) + set --global __authctl_comp_results $__authctl_perform_completion_once_result[1..-2] + + __authctl_debug "Completions are: $__authctl_comp_results" + __authctl_debug "Directive is: $directive" + + set -l shellCompDirectiveError 1 + set -l shellCompDirectiveNoSpace 2 + set -l shellCompDirectiveNoFileComp 4 + set -l shellCompDirectiveFilterFileExt 8 + set -l shellCompDirectiveFilterDirs 16 + + if test -z "$directive" + set directive 0 + end + + set -l compErr (math (math --scale 0 $directive / $shellCompDirectiveError) % 2) + if test $compErr -eq 1 + __authctl_debug "Received error directive: aborting." + # Might as well do file completion, in case it helps + return 1 + end + + set -l filefilter (math (math --scale 0 $directive / $shellCompDirectiveFilterFileExt) % 2) + set -l dirfilter (math (math --scale 0 $directive / $shellCompDirectiveFilterDirs) % 2) + if test $filefilter -eq 1; or test $dirfilter -eq 1 + __authctl_debug "File extension filtering or directory filtering not supported" + # Do full file completion instead + return 1 + end + + set -l nospace (math (math --scale 0 $directive / $shellCompDirectiveNoSpace) % 2) + set -l nofiles (math (math --scale 0 $directive / $shellCompDirectiveNoFileComp) % 2) + + __authctl_debug "nospace: $nospace, nofiles: $nofiles" + + # If we want to prevent a space, or if file completion is NOT disabled, + # we need to count the number of valid completions. + # To do so, we will filter on prefix as the completions we have received + # may not already be filtered so as to allow fish to match on different + # criteria than the prefix. + if test $nospace -ne 0; or test $nofiles -eq 0 + set -l prefix (commandline -t | string escape --style=regex) + __authctl_debug "prefix: $prefix" + + set -l completions (string match -r -- "^$prefix.*" $__authctl_comp_results) + set --global __authctl_comp_results $completions + __authctl_debug "Filtered completions are: $__authctl_comp_results" + + # Important not to quote the variable for count to work + set -l numComps (count $__authctl_comp_results) + __authctl_debug "numComps: $numComps" + + if test $numComps -eq 1; and test $nospace -ne 0 + # We must first split on \t to get rid of the descriptions to be + # able to check what the actual completion will be. + # We don't need descriptions anyway since there is only a single + # real completion which the shell will expand immediately. + set -l split (string split --max 1 \t $__authctl_comp_results[1]) + + # Fish won't add a space if the completion ends with any + # of the following characters: @=/:., + set -l lastChar (string sub -s -1 -- $split) + if not string match -r -q "[@=/:.,]" -- "$lastChar" + # In other cases, to support the "nospace" directive we trick the shell + # by outputting an extra, longer completion. + __authctl_debug "Adding second completion to perform nospace directive" + set --global __authctl_comp_results $split[1] $split[1]. + __authctl_debug "Completions are now: $__authctl_comp_results" + end + end + + if test $numComps -eq 0; and test $nofiles -eq 0 + # To be consistent with bash and zsh, we only trigger file + # completion when there are no other completions + __authctl_debug "Requesting file completion" + return 1 + end + end + + return 0 +end + +# Since Fish completions are only loaded once the user triggers them, we trigger them ourselves +# so we can properly delete any completions provided by another script. +# Only do this if the program can be found, or else fish may print some errors; besides, +# the existing completions will only be loaded if the program can be found. +if type -q "authctl" + # The space after the program name is essential to trigger completion for the program + # and not completion of the program name itself. + # Also, we use '> /dev/null 2>&1' since '&>' is not supported in older versions of fish. + complete --do-complete "authctl " > /dev/null 2>&1 +end + +# Remove any pre-existing completions for the program since we will be handling all of them. +complete -c authctl -e + +# this will get called after the two calls below and clear the $__authctl_perform_completion_once_result global +complete -c authctl -n '__authctl_clear_perform_completion_once_result' +# The call to __authctl_prepare_completions will setup __authctl_comp_results +# which provides the program's completion choices. +# If this doesn't require order preservation, we don't use the -k flag +complete -c authctl -n 'not __authctl_requires_order_preservation && __authctl_prepare_completions' -f -a '$__authctl_comp_results' +# otherwise we use the -k flag +complete -k -c authctl -n '__authctl_requires_order_preservation && __authctl_prepare_completions' -f -a '$__authctl_comp_results' diff --git a/shell-completion/generate.go b/shell-completion/generate.go new file mode 100644 index 0000000000..308ea397c4 --- /dev/null +++ b/shell-completion/generate.go @@ -0,0 +1,9 @@ +//go:build generate + +// TiCS: disabled // This is a helper file to generate the shell completion code. + +//go:generate sh -c "go run ../cmd/authctl/main.go completion bash > bash/authctl" +//go:generate sh -c "go run ../cmd/authctl/main.go completion zsh > zsh/_authctl" +//go:generate sh -c "go run ../cmd/authctl/main.go completion fish > fish/authctl.fish" + +package shell_completion diff --git a/shell-completion/zsh/_authctl b/shell-completion/zsh/_authctl new file mode 100644 index 0000000000..fa9ff15acd --- /dev/null +++ b/shell-completion/zsh/_authctl @@ -0,0 +1,212 @@ +#compdef authctl +compdef _authctl authctl + +# zsh completion for authctl -*- shell-script -*- + +__authctl_debug() +{ + local file="$BASH_COMP_DEBUG_FILE" + if [[ -n ${file} ]]; then + echo "$*" >> "${file}" + fi +} + +_authctl() +{ + local shellCompDirectiveError=1 + local shellCompDirectiveNoSpace=2 + local shellCompDirectiveNoFileComp=4 + local shellCompDirectiveFilterFileExt=8 + local shellCompDirectiveFilterDirs=16 + local shellCompDirectiveKeepOrder=32 + + local lastParam lastChar flagPrefix requestComp out directive comp lastComp noSpace keepOrder + local -a completions + + __authctl_debug "\n========= starting completion logic ==========" + __authctl_debug "CURRENT: ${CURRENT}, words[*]: ${words[*]}" + + # The user could have moved the cursor backwards on the command-line. + # We need to trigger completion from the $CURRENT location, so we need + # to truncate the command-line ($words) up to the $CURRENT location. + # (We cannot use $CURSOR as its value does not work when a command is an alias.) + words=("${=words[1,CURRENT]}") + __authctl_debug "Truncated words[*]: ${words[*]}," + + lastParam=${words[-1]} + lastChar=${lastParam[-1]} + __authctl_debug "lastParam: ${lastParam}, lastChar: ${lastChar}" + + # For zsh, when completing a flag with an = (e.g., authctl -n=) + # completions must be prefixed with the flag + setopt local_options BASH_REMATCH + if [[ "${lastParam}" =~ '-.*=' ]]; then + # We are dealing with a flag with an = + flagPrefix="-P ${BASH_REMATCH}" + fi + + # Prepare the command to obtain completions + requestComp="${words[1]} __complete ${words[2,-1]}" + if [ "${lastChar}" = "" ]; then + # If the last parameter is complete (there is a space following it) + # We add an extra empty parameter so we can indicate this to the go completion code. + __authctl_debug "Adding extra empty parameter" + requestComp="${requestComp} \"\"" + fi + + __authctl_debug "About to call: eval ${requestComp}" + + # Use eval to handle any environment variables and such + out=$(eval ${requestComp} 2>/dev/null) + __authctl_debug "completion output: ${out}" + + # Extract the directive integer following a : from the last line + local lastLine + while IFS='\n' read -r line; do + lastLine=${line} + done < <(printf "%s\n" "${out[@]}") + __authctl_debug "last line: ${lastLine}" + + if [ "${lastLine[1]}" = : ]; then + directive=${lastLine[2,-1]} + # Remove the directive including the : and the newline + local suffix + (( suffix=${#lastLine}+2)) + out=${out[1,-$suffix]} + else + # There is no directive specified. Leave $out as is. + __authctl_debug "No directive found. Setting do default" + directive=0 + fi + + __authctl_debug "directive: ${directive}" + __authctl_debug "completions: ${out}" + __authctl_debug "flagPrefix: ${flagPrefix}" + + if [ $((directive & shellCompDirectiveError)) -ne 0 ]; then + __authctl_debug "Completion received error. Ignoring completions." + return + fi + + local activeHelpMarker="_activeHelp_ " + local endIndex=${#activeHelpMarker} + local startIndex=$((${#activeHelpMarker}+1)) + local hasActiveHelp=0 + while IFS='\n' read -r comp; do + # Check if this is an activeHelp statement (i.e., prefixed with $activeHelpMarker) + if [ "${comp[1,$endIndex]}" = "$activeHelpMarker" ];then + __authctl_debug "ActiveHelp found: $comp" + comp="${comp[$startIndex,-1]}" + if [ -n "$comp" ]; then + compadd -x "${comp}" + __authctl_debug "ActiveHelp will need delimiter" + hasActiveHelp=1 + fi + + continue + fi + + if [ -n "$comp" ]; then + # If requested, completions are returned with a description. + # The description is preceded by a TAB character. + # For zsh's _describe, we need to use a : instead of a TAB. + # We first need to escape any : as part of the completion itself. + comp=${comp//:/\\:} + + local tab="$(printf '\t')" + comp=${comp//$tab/:} + + __authctl_debug "Adding completion: ${comp}" + completions+=${comp} + lastComp=$comp + fi + done < <(printf "%s\n" "${out[@]}") + + # Add a delimiter after the activeHelp statements, but only if: + # - there are completions following the activeHelp statements, or + # - file completion will be performed (so there will be choices after the activeHelp) + if [ $hasActiveHelp -eq 1 ]; then + if [ ${#completions} -ne 0 ] || [ $((directive & shellCompDirectiveNoFileComp)) -eq 0 ]; then + __authctl_debug "Adding activeHelp delimiter" + compadd -x "--" + hasActiveHelp=0 + fi + fi + + if [ $((directive & shellCompDirectiveNoSpace)) -ne 0 ]; then + __authctl_debug "Activating nospace." + noSpace="-S ''" + fi + + if [ $((directive & shellCompDirectiveKeepOrder)) -ne 0 ]; then + __authctl_debug "Activating keep order." + keepOrder="-V" + fi + + if [ $((directive & shellCompDirectiveFilterFileExt)) -ne 0 ]; then + # File extension filtering + local filteringCmd + filteringCmd='_files' + for filter in ${completions[@]}; do + if [ ${filter[1]} != '*' ]; then + # zsh requires a glob pattern to do file filtering + filter="\*.$filter" + fi + filteringCmd+=" -g $filter" + done + filteringCmd+=" ${flagPrefix}" + + __authctl_debug "File filtering command: $filteringCmd" + _arguments '*:filename:'"$filteringCmd" + elif [ $((directive & shellCompDirectiveFilterDirs)) -ne 0 ]; then + # File completion for directories only + local subdir + subdir="${completions[1]}" + if [ -n "$subdir" ]; then + __authctl_debug "Listing directories in $subdir" + pushd "${subdir}" >/dev/null 2>&1 + else + __authctl_debug "Listing directories in ." + fi + + local result + _arguments '*:dirname:_files -/'" ${flagPrefix}" + result=$? + if [ -n "$subdir" ]; then + popd >/dev/null 2>&1 + fi + return $result + else + __authctl_debug "Calling _describe" + if eval _describe $keepOrder "completions" completions $flagPrefix $noSpace; then + __authctl_debug "_describe found some completions" + + # Return the success of having called _describe + return 0 + else + __authctl_debug "_describe did not find completions." + __authctl_debug "Checking if we should do file completion." + if [ $((directive & shellCompDirectiveNoFileComp)) -ne 0 ]; then + __authctl_debug "deactivating file completion" + + # We must return an error code here to let zsh know that there were no + # completions found by _describe; this is what will trigger other + # matching algorithms to attempt to find completions. + # For example zsh can match letters in the middle of words. + return 1 + else + # Perform file completion + __authctl_debug "Activating file completion" + + # We must return the result of this command, so it must be the + # last command, or else we must store its result to return it. + _arguments '*:filename:_files'" ${flagPrefix}" + fi + fi + fi +} + +# don't run the completion function when being source-ed or eval-ed +if [ "$funcstack[1]" = "_authctl" ]; then + _authctl +fi