diff --git a/.github/actions/setup-go-tests/action.yaml b/.github/actions/setup-go-tests/action.yaml index cc442808f7..ea4e14aa2c 100644 --- a/.github/actions/setup-go-tests/action.yaml +++ b/.github/actions/setup-go-tests/action.yaml @@ -26,10 +26,7 @@ runs: sudo apt-get install -y protobuf-compiler sudo apt-get install -y ${{ env.go_build_dependencies }} ${{ env.go_test_dependencies}} - - # Load the apparmor profile for bubblewrap. - sudo ln -s /usr/share/apparmor/extra-profiles/bwrap-userns-restrict /etc/apparmor.d/ - sudo apparmor_parser /etc/apparmor.d/bwrap-userns-restrict + echo "::endgroup::" - name: Install gotestfmt and our wrapper script diff --git a/.github/workflows/qa.yaml b/.github/workflows/qa.yaml index 9ec6289d1a..8dfb53d1c7 100644 --- a/.github/workflows/qa.yaml +++ b/.github/workflows/qa.yaml @@ -36,8 +36,7 @@ env: libpam-dev libpwquality-dev - go_test_dependencies: >- - apparmor-profiles + go_test_dependencies: >- bubblewrap cracklib-runtime git-delta @@ -333,8 +332,10 @@ jobs: # Print executed commands to ease debugging set -x + echo "::group::Install llvm-symbolizer" # For llvm-symbolizer sudo apt-get install -y llvm + echo "::endgroup::" go test -C ./pam/internal -json -asan -gcflags=all="${GO_GC_FLAGS}" -failfast -timeout ${GO_TESTS_TIMEOUT} ./... | \ gotestfmt --logfile "${AUTHD_TESTS_ARTIFACTS_PATH}/gotestfmt.pam-internal-asan.log" || exit_code=$? diff --git a/cmd/authctl/group/group.go b/cmd/authctl/group/group.go new file mode 100644 index 0000000000..01a7c2b88b --- /dev/null +++ b/cmd/authctl/group/group.go @@ -0,0 +1,18 @@ +// Package group provides utilities for managing group operations. +package group + +import ( + "github.com/spf13/cobra" +) + +// GroupCmd is a command to perform group-related operations. +var GroupCmd = &cobra.Command{ + Use: "group", + Short: "Commands related to groups", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { return cmd.Usage() }, +} + +func init() { + GroupCmd.AddCommand(setGIDCmd) +} diff --git a/cmd/authctl/group/group_test.go b/cmd/authctl/group/group_test.go new file mode 100644 index 0000000000..34d3093521 --- /dev/null +++ b/cmd/authctl/group/group_test.go @@ -0,0 +1,59 @@ +package group_test + +import ( + "fmt" + "os" + "os/exec" + "testing" + + "github.com/canonical/authd/internal/testutils" +) + +var authctlPath string +var daemonPath string + +func TestGroupCommand(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{"group"}, tc.args...)...) + testutils.CheckCommand(t, cmd, tc.expectedExitCode) + }) + } +} + +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.BuildAuthdWithExampleBroker() + if err != nil { + fmt.Fprintf(os.Stderr, "Setup: %v\n", err) + os.Exit(1) + } + defer daemonCleanup() + + m.Run() +} diff --git a/cmd/authctl/group/set-gid.go b/cmd/authctl/group/set-gid.go new file mode 100644 index 0000000000..a70262b6de --- /dev/null +++ b/cmd/authctl/group/set-gid.go @@ -0,0 +1,77 @@ +package group + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + + "github.com/canonical/authd/cmd/authctl/internal/client" + "github.com/canonical/authd/cmd/authctl/internal/completion" + "github.com/canonical/authd/internal/proto/authd" + "github.com/spf13/cobra" +) + +// setGIDCmd is a command to set the GID of a group managed by authd. +var setGIDCmd = &cobra.Command{ + Use: "set-gid ", + Short: "Set the GID of a group managed by authd", + Long: `Set the GID of a group managed by authd to the specified value. + +The new GID value must be unique and non-negative. + +When a group's GID is changed, any users whose primary group is set to this group +will have their primary group GID updated. The home directories of these users and +files within them owned by the group will be updated to the new GID. + +Files outside users' home directories are not updated and must be changed +manually. Note that changing a GID can be unsafe if files on the system are +still owned by the original GID: those files may become accessible to a +different group that is later assigned that GID. To change group ownership of +all files on the system from the old GID to the new GID, run: + + sudo chown -R --from :OLD_GID :NEW_GID / + +This command requires root privileges. + +Examples: + authctl group set-gid staff 30000 + authctl group set-gid developers 40000`, + Args: cobra.ExactArgs(2), + ValidArgsFunction: completion.Groups, + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + gidStr := args[1] + gid, err := strconv.ParseUint(gidStr, 10, 32) + if err != nil { + // Remove the "strconv.ParseUint: parsing ..." part from the error message + // because it doesn't add any useful information. + if unwrappedErr := errors.Unwrap(err); unwrappedErr != nil { + err = unwrappedErr + } + return fmt.Errorf("failed to parse GID %q: %w", gidStr, err) + } + + client, err := client.NewUserServiceClient() + if err != nil { + return err + } + + resp, err := client.SetGroupID(context.Background(), &authd.SetGroupIDRequest{ + Name: name, + Id: uint32(gid), + Lang: os.Getenv("LANG"), + }) + if err != nil { + return err + } + + // Print any warnings returned by the server. + for _, warning := range resp.Warnings { + fmt.Fprintf(os.Stderr, "Warning: %s\n", warning) + } + + return nil + }, +} diff --git a/cmd/authctl/group/set-gid_test.go b/cmd/authctl/group/set-gid_test.go new file mode 100644 index 0000000000..97d8456a74 --- /dev/null +++ b/cmd/authctl/group/set-gid_test.go @@ -0,0 +1,87 @@ +package group_test + +import ( + "math" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + + "github.com/canonical/authd/internal/testutils" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" +) + +func TestSetGIDCommand(t *testing.T) { + // We can't run these tests in parallel because the daemon with the example + // broker which we're using here uses userslocking.Z_ForTests_OverrideLocking() + // which makes userslocking.WriteLock() return an error immediately when the lock + // is already held - unlike the normal behavior which tries to acquire the lock + // for 15 seconds before returning an error. + + daemonSocket := testutils.StartAuthd(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 + authdUnavailable bool + + expectedExitCode int + }{ + "Set_group_gid_success": { + args: []string{"set-gid", "group1", "123456"}, + expectedExitCode: 0, + }, + + "Error_when_group_does_not_exist": { + args: []string{"set-gid", "invalidgroup", "123456"}, + expectedExitCode: int(codes.NotFound), + }, + "Error_when_gid_is_invalid": { + args: []string{"set-gid", "group1", "invalidgid"}, + expectedExitCode: 1, + }, + "Error_when_gid_is_too_large": { + args: []string{"set-gid", "group1", strconv.Itoa(math.MaxInt32 + 1)}, + expectedExitCode: int(codes.Unknown), + }, + "Error_when_gid_is_already_taken": { + args: []string{"set-gid", "group1", "0"}, + expectedExitCode: int(codes.Unknown), + }, + "Error_when_gid_is_negative": { + args: []string{"set-gid", "group1", "-1000"}, + expectedExitCode: 1, + }, + "Error_when_authd_is_unavailable": { + args: []string{"set-gid", "group1", "123456"}, + authdUnavailable: true, + expectedExitCode: int(codes.Unavailable), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if tc.authdUnavailable { + origValue := os.Getenv("AUTHD_SOCKET") + err := os.Setenv("AUTHD_SOCKET", "/non-existent") + require.NoError(t, err, "Failed to set AUTHD_SOCKET environment variable") + t.Cleanup(func() { + err := os.Setenv("AUTHD_SOCKET", origValue) + require.NoError(t, err, "Failed to restore AUTHD_SOCKET environment variable") + }) + } + + //nolint:gosec // G204 it's safe to use exec.Command with a variable here + cmd := exec.Command(authctlPath, append([]string{"group"}, tc.args...)...) + testutils.CheckCommand(t, cmd, tc.expectedExitCode) + }) + } +} diff --git a/cmd/authctl/group/testdata/db/one_user_and_group.db.yaml b/cmd/authctl/group/testdata/db/one_user_and_group.db.yaml new file mode 100644 index 0000000000..77567897ae --- /dev/null +++ b/cmd/authctl/group/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/group/testdata/empty.group b/cmd/authctl/group/testdata/empty.group new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cmd/authctl/group/testdata/golden/TestGroupCommand/Error_on_invalid_command b/cmd/authctl/group/testdata/golden/TestGroupCommand/Error_on_invalid_command new file mode 100644 index 0000000000..ecd4f9d8a8 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestGroupCommand/Error_on_invalid_command @@ -0,0 +1,13 @@ +Usage: + authctl group [flags] + authctl group [command] + +Available Commands: + set-gid Set the GID of a group managed by authd + +Flags: + -h, --help help for group + +Use "authctl group [command] --help" for more information about a command. + +unknown command "invalid-command" for "authctl group" diff --git a/cmd/authctl/group/testdata/golden/TestGroupCommand/Error_on_invalid_flag b/cmd/authctl/group/testdata/golden/TestGroupCommand/Error_on_invalid_flag new file mode 100644 index 0000000000..7ca5da1f79 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestGroupCommand/Error_on_invalid_flag @@ -0,0 +1,13 @@ +Usage: + authctl group [flags] + authctl group [command] + +Available Commands: + set-gid Set the GID of a group managed by authd + +Flags: + -h, --help help for group + +Use "authctl group [command] --help" for more information about a command. + +unknown flag: --invalid-flag diff --git a/cmd/authctl/group/testdata/golden/TestGroupCommand/Help_flag b/cmd/authctl/group/testdata/golden/TestGroupCommand/Help_flag new file mode 100644 index 0000000000..ad64bb0b01 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestGroupCommand/Help_flag @@ -0,0 +1,13 @@ +Commands related to groups + +Usage: + authctl group [flags] + authctl group [command] + +Available Commands: + set-gid Set the GID of a group managed by authd + +Flags: + -h, --help help for group + +Use "authctl group [command] --help" for more information about a command. diff --git a/cmd/authctl/group/testdata/golden/TestGroupCommand/Usage_message_when_no_args b/cmd/authctl/group/testdata/golden/TestGroupCommand/Usage_message_when_no_args new file mode 100644 index 0000000000..1c53f30766 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestGroupCommand/Usage_message_when_no_args @@ -0,0 +1,11 @@ +Usage: + authctl group [flags] + authctl group [command] + +Available Commands: + set-gid Set the GID of a group managed by authd + +Flags: + -h, --help help for group + +Use "authctl group [command] --help" for more information about a command. diff --git a/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_authd_is_unavailable b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_authd_is_unavailable new file mode 100644 index 0000000000..ba5b5abcba --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_authd_is_unavailable @@ -0,0 +1 @@ +Error: connection error: desc = "transport: Error while dialing: dial unix /non-existent: connect: no such file or directory" diff --git a/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_already_taken b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_already_taken new file mode 100644 index 0000000000..673356ab64 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_already_taken @@ -0,0 +1 @@ +Error: GID 0 already exists diff --git a/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_invalid b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_invalid new file mode 100644 index 0000000000..9fb3f91660 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_invalid @@ -0,0 +1 @@ +failed to parse GID "invalidgid": invalid syntax diff --git a/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_negative b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_negative new file mode 100644 index 0000000000..bc3e50c790 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_negative @@ -0,0 +1,7 @@ +Usage: + authctl group set-gid [flags] + +Flags: + -h, --help help for set-gid + +unknown shorthand flag: '1' in -1000 diff --git a/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_too_large b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_too_large new file mode 100644 index 0000000000..be456ffb84 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_too_large @@ -0,0 +1 @@ +Error: GID 2147483648 is too large to convert to int32 diff --git a/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_group_does_not_exist b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_group_does_not_exist new file mode 100644 index 0000000000..bcbc1916c2 --- /dev/null +++ b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_group_does_not_exist @@ -0,0 +1 @@ +Error: group "invalidgroup" not found diff --git a/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Set_group_gid_success b/cmd/authctl/group/testdata/golden/TestSetGIDCommand/Set_group_gid_success new file mode 100644 index 0000000000..e69de29bb2 diff --git a/cmd/authctl/internal/client/client.go b/cmd/authctl/internal/client/client.go new file mode 100644 index 0000000000..fff6efe7ff --- /dev/null +++ b/cmd/authctl/internal/client/client.go @@ -0,0 +1,35 @@ +// Package client provides a utility function to create a gRPC client for the authd service. +package client + +import ( + "fmt" + "os" + "regexp" + + "github.com/canonical/authd/internal/consts" + "github.com/canonical/authd/internal/proto/authd" + "google.golang.org/grpc" + "google.golang.org/grpc/credentials/insecure" +) + +// 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 +} diff --git a/cmd/authctl/internal/completion/completion.go b/cmd/authctl/internal/completion/completion.go new file mode 100644 index 0000000000..0070afd743 --- /dev/null +++ b/cmd/authctl/internal/completion/completion.go @@ -0,0 +1,77 @@ +// Package completion provides completion functions for authctl. +package completion + +import ( + "context" + "time" + + "github.com/canonical/authd/cmd/authctl/internal/client" + "github.com/canonical/authd/internal/proto/authd" + "github.com/spf13/cobra" + "google.golang.org/grpc/status" +) + +const timeout = 5 * time.Second + +// Users returns the list of authd users for shell completion. +func Users(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + svc, err := client.NewUserServiceClient() + if err != nil { + return showError(err) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), timeout) + defer cancel() + + resp, err := svc.ListUsers(ctx, &authd.Empty{}) + if err != nil { + return showError(err) + } + + var userNames []string + for _, user := range resp.Users { + userNames = append(userNames, user.Name) + } + + return userNames, cobra.ShellCompDirectiveNoFileComp +} + +// Groups returns the list of authd groups for shell completion. +func Groups(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + c, err := client.NewUserServiceClient() + if err != nil { + return showError(err) + } + + ctx, cancel := context.WithTimeout(cmd.Context(), timeout) + defer cancel() + + resp, err := c.ListGroups(ctx, &authd.Empty{}) + if err != nil { + return showError(err) + } + + var groupNames []string + for _, group := range resp.Groups { + groupNames = append(groupNames, group.Name) + } + + return groupNames, cobra.ShellCompDirectiveNoFileComp +} + +// NoArgs returns no arguments and disables file completion. +func NoArgs(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + return nil, cobra.ShellCompDirectiveNoFileComp +} + +func showError(err error) ([]string, cobra.ShellCompDirective) { + if s, ok := status.FromError(err); ok { + return showMessage(s.Message()) + } + + return showMessage(err.Error()) +} + +func showMessage(msg string) ([]string, cobra.ShellCompDirective) { + return cobra.AppendActiveHelp(nil, msg), cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/authctl/main.go b/cmd/authctl/main.go index 9cf65cd678..6a12494ec8 100644 --- a/cmd/authctl/main.go +++ b/cmd/authctl/main.go @@ -5,6 +5,7 @@ import ( "fmt" "os" + "github.com/canonical/authd/cmd/authctl/group" "github.com/canonical/authd/cmd/authctl/user" "github.com/spf13/cobra" "google.golang.org/grpc/codes" @@ -35,6 +36,7 @@ func init() { cobra.EnableCommandSorting = false rootCmd.AddCommand(user.UserCmd) + rootCmd.AddCommand(group.GroupCmd) } func main() { diff --git a/cmd/authctl/main_test.go b/cmd/authctl/main_test.go index 7fce2b4ecf..52c2a0ee62 100644 --- a/cmd/authctl/main_test.go +++ b/cmd/authctl/main_test.go @@ -7,7 +7,6 @@ import ( "testing" "github.com/canonical/authd/internal/testutils" - "github.com/canonical/authd/internal/testutils/golden" ) var authctlPath string @@ -34,22 +33,7 @@ func TestRootCommand(t *testing.T) { //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) + testutils.CheckCommand(t, cmd, tc.expectedExitCode) }) } } diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_command b/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_command index 31b2b174d7..ffcf63f66e 100644 --- a/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_command +++ b/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_command @@ -4,6 +4,7 @@ Usage: Available Commands: user Commands related to users + group Commands related to groups help Help about any command Flags: diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_flag b/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_flag index f900c50639..427c0c5bf3 100644 --- a/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_flag +++ b/cmd/authctl/testdata/golden/TestRootCommand/Error_on_invalid_flag @@ -4,6 +4,7 @@ Usage: Available Commands: user Commands related to users + group Commands related to groups help Help about any command Flags: diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Help_command b/cmd/authctl/testdata/golden/TestRootCommand/Help_command index 111485b844..93bcbe0800 100644 --- a/cmd/authctl/testdata/golden/TestRootCommand/Help_command +++ b/cmd/authctl/testdata/golden/TestRootCommand/Help_command @@ -6,6 +6,7 @@ Usage: Available Commands: user Commands related to users + group Commands related to groups help Help about any command Flags: diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Help_flag b/cmd/authctl/testdata/golden/TestRootCommand/Help_flag index 111485b844..93bcbe0800 100644 --- a/cmd/authctl/testdata/golden/TestRootCommand/Help_flag +++ b/cmd/authctl/testdata/golden/TestRootCommand/Help_flag @@ -6,6 +6,7 @@ Usage: Available Commands: user Commands related to users + group Commands related to groups help Help about any command Flags: diff --git a/cmd/authctl/testdata/golden/TestRootCommand/Usage_message_when_no_args b/cmd/authctl/testdata/golden/TestRootCommand/Usage_message_when_no_args index 46485fdb0b..d2577969fd 100644 --- a/cmd/authctl/testdata/golden/TestRootCommand/Usage_message_when_no_args +++ b/cmd/authctl/testdata/golden/TestRootCommand/Usage_message_when_no_args @@ -4,6 +4,7 @@ Usage: Available Commands: user Commands related to users + group Commands related to groups help Help about any command Flags: diff --git a/cmd/authctl/user/lock.go b/cmd/authctl/user/lock.go index a1c3189687..11e6522f3e 100644 --- a/cmd/authctl/user/lock.go +++ b/cmd/authctl/user/lock.go @@ -3,17 +3,20 @@ package user import ( "context" + "github.com/canonical/authd/cmd/authctl/internal/client" + "github.com/canonical/authd/cmd/authctl/internal/completion" "github.com/canonical/authd/internal/proto/authd" "github.com/spf13/cobra" ) // 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), + Use: "lock ", + Short: "Lock (disable) a user managed by authd", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.Users, RunE: func(cmd *cobra.Command, args []string) error { - client, err := NewUserServiceClient() + client, err := client.NewUserServiceClient() if err != nil { return err } diff --git a/cmd/authctl/user/lock_test.go b/cmd/authctl/user/lock_test.go new file mode 100644 index 0000000000..eb89ac349a --- /dev/null +++ b/cmd/authctl/user/lock_test.go @@ -0,0 +1,44 @@ +package user_test + +import ( + "os" + "os/exec" + "path/filepath" + "testing" + + "github.com/canonical/authd/internal/testutils" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" +) + +func TestUserLockCommand(t *testing.T) { + t.Parallel() + + daemonSocket := testutils.StartAuthd(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...)...) + testutils.CheckCommand(t, cmd, tc.expectedExitCode) + }) + } +} diff --git a/cmd/authctl/user/set-uid.go b/cmd/authctl/user/set-uid.go new file mode 100644 index 0000000000..ae34c62a81 --- /dev/null +++ b/cmd/authctl/user/set-uid.go @@ -0,0 +1,84 @@ +package user + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + + "github.com/canonical/authd/cmd/authctl/internal/client" + "github.com/canonical/authd/cmd/authctl/internal/completion" + "github.com/canonical/authd/internal/proto/authd" + "github.com/spf13/cobra" +) + +// setUIDCmd is a command to set the UID of a user managed by authd. +var setUIDCmd = &cobra.Command{ + Use: "set-uid ", + Short: "Set the UID of a user managed by authd", + Long: `Set the UID of a user managed by authd to the specified value. + +The new UID value must be unique and non-negative. + +The user's home directory and any files within it owned by the user will +automatically have their ownership updated to the new UID. + +Files outside the user's home directory are not updated and must be changed +manually. Note that changing a UID can be unsafe if files on the system are +still owned by the original UID: those files may become accessible to a different +account that is later assigned that UID. To change ownership of all files on the +system from the old UID to the new UID, run: + + sudo chown -R --from OLD_UID NEW_UID / + +This command requires root privileges. + +Examples: + authctl user set-uid john 15000 + authctl user set-uid alice 20000`, + Args: cobra.ExactArgs(2), + ValidArgsFunction: setUIDCompletionFunc, + RunE: func(cmd *cobra.Command, args []string) error { + name := args[0] + uidStr := args[1] + uid, err := strconv.ParseUint(uidStr, 10, 32) + if err != nil { + // Remove the "strconv.ParseUint: parsing ..." part from the error message + // because it doesn't add any useful information. + if unwrappedErr := errors.Unwrap(err); unwrappedErr != nil { + err = unwrappedErr + } + return fmt.Errorf("failed to parse UID %q: %w", uidStr, err) + } + + client, err := client.NewUserServiceClient() + if err != nil { + return err + } + + resp, err := client.SetUserID(context.Background(), &authd.SetUserIDRequest{ + Name: name, + Id: uint32(uid), + Lang: os.Getenv("LANG"), + }) + if err != nil { + return err + } + + // Print any warnings returned by the server. + for _, warning := range resp.Warnings { + fmt.Fprintf(os.Stderr, "Warning: %s\n", warning) + } + + return nil + }, +} + +func setUIDCompletionFunc(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { + if len(args) == 0 { + return completion.Users(cmd, args, toComplete) + } + + return nil, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/authctl/user/set-uid_test.go b/cmd/authctl/user/set-uid_test.go new file mode 100644 index 0000000000..ffdcda0ad3 --- /dev/null +++ b/cmd/authctl/user/set-uid_test.go @@ -0,0 +1,87 @@ +package user_test + +import ( + "math" + "os" + "os/exec" + "path/filepath" + "strconv" + "testing" + + "github.com/canonical/authd/internal/testutils" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/codes" +) + +func TestSetUIDCommand(t *testing.T) { + // We can't run these tests in parallel because the daemon with the example + // broker which we're using here uses userslocking.Z_ForTests_OverrideLocking() + // which makes userslocking.WriteLock() return an error immediately when the lock + // is already held - unlike the normal behavior which tries to acquire the lock + // for 15 seconds before returning an error. + + daemonSocket := testutils.StartAuthd(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 + authdUnavailable bool + + expectedExitCode int + }{ + "Set_user_uid_success": { + args: []string{"set-uid", "user1", "123456"}, + expectedExitCode: 0, + }, + + "Error_when_user_does_not_exist": { + args: []string{"set-uid", "invaliduser", "123456"}, + expectedExitCode: int(codes.NotFound), + }, + "Error_when_uid_is_invalid": { + args: []string{"set-uid", "user1", "invaliduid"}, + expectedExitCode: 1, + }, + "Error_when_uid_is_too_large": { + args: []string{"set-uid", "user1", strconv.Itoa(math.MaxInt32 + 1)}, + expectedExitCode: int(codes.Unknown), + }, + "Error_when_uid_is_already_taken": { + args: []string{"set-uid", "user1", "0"}, + expectedExitCode: int(codes.Unknown), + }, + "Error_when_uid_is_negative": { + args: []string{"set-uid", "user1", "-1000"}, + expectedExitCode: 1, + }, + "Error_when_authd_is_unavailable": { + args: []string{"set-uid", "user1", "123456"}, + authdUnavailable: true, + expectedExitCode: int(codes.Unavailable), + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + if tc.authdUnavailable { + origValue := os.Getenv("AUTHD_SOCKET") + err := os.Setenv("AUTHD_SOCKET", "/non-existent") + require.NoError(t, err, "Failed to set AUTHD_SOCKET environment variable") + t.Cleanup(func() { + err := os.Setenv("AUTHD_SOCKET", origValue) + require.NoError(t, err, "Failed to restore AUTHD_SOCKET environment variable") + }) + } + + //nolint:gosec // G204 it's safe to use exec.Command with a variable here + cmd := exec.Command(authctlPath, append([]string{"user"}, tc.args...)...) + testutils.CheckCommand(t, cmd, tc.expectedExitCode) + }) + } +} diff --git a/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_authd_is_unavailable b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_authd_is_unavailable new file mode 100644 index 0000000000..ba5b5abcba --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_authd_is_unavailable @@ -0,0 +1 @@ +Error: connection error: desc = "transport: Error while dialing: dial unix /non-existent: connect: no such file or directory" diff --git a/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_already_taken b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_already_taken new file mode 100644 index 0000000000..ed14c2f9ad --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_already_taken @@ -0,0 +1 @@ +Error: UID 0 already exists diff --git a/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_invalid b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_invalid new file mode 100644 index 0000000000..acf1ab1bfd --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_invalid @@ -0,0 +1 @@ +failed to parse UID "invaliduid": invalid syntax diff --git a/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_negative b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_negative new file mode 100644 index 0000000000..33c67cbd74 --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_negative @@ -0,0 +1,7 @@ +Usage: + authctl user set-uid [flags] + +Flags: + -h, --help help for set-uid + +unknown shorthand flag: '1' in -1000 diff --git a/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_too_large b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_too_large new file mode 100644 index 0000000000..e89c9a4386 --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_too_large @@ -0,0 +1 @@ +Error: UID 2147483648 is too large to convert to int32 diff --git a/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_user_does_not_exist b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_user_does_not_exist new file mode 100644 index 0000000000..93dd7dd5ff --- /dev/null +++ b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_user_does_not_exist @@ -0,0 +1 @@ +Error: user "invaliduser" not found diff --git a/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Set_user_uid_success b/cmd/authctl/user/testdata/golden/TestSetUIDCommand/Set_user_uid_success 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 index ce84afc2e3..a66b03e2c9 100644 --- a/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_command +++ b/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_command @@ -5,6 +5,7 @@ Usage: Available Commands: lock Lock (disable) a user managed by authd unlock Unlock (enable) a user managed by authd + set-uid Set the UID of a user managed by authd Flags: -h, --help help for 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 index d3b1824aea..3408cec6d8 100644 --- a/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_flag +++ b/cmd/authctl/user/testdata/golden/TestUserCommand/Error_on_invalid_flag @@ -5,6 +5,7 @@ Usage: Available Commands: lock Lock (disable) a user managed by authd unlock Unlock (enable) a user managed by authd + set-uid Set the UID of a user managed by authd Flags: -h, --help help for user diff --git a/cmd/authctl/user/testdata/golden/TestUserCommand/Help_flag b/cmd/authctl/user/testdata/golden/TestUserCommand/Help_flag index e56b67c1da..ee4765c1cc 100644 --- a/cmd/authctl/user/testdata/golden/TestUserCommand/Help_flag +++ b/cmd/authctl/user/testdata/golden/TestUserCommand/Help_flag @@ -7,6 +7,7 @@ Usage: Available Commands: lock Lock (disable) a user managed by authd unlock Unlock (enable) a user managed by authd + set-uid Set the UID of a user managed by authd Flags: -h, --help help for user 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 index 76259d0f98..d83ced5684 100644 --- a/cmd/authctl/user/testdata/golden/TestUserCommand/Usage_message_when_no_args +++ b/cmd/authctl/user/testdata/golden/TestUserCommand/Usage_message_when_no_args @@ -5,6 +5,7 @@ Usage: Available Commands: lock Lock (disable) a user managed by authd unlock Unlock (enable) a user managed by authd + set-uid Set the UID of a user managed by authd Flags: -h, --help help for user diff --git a/cmd/authctl/user/unlock.go b/cmd/authctl/user/unlock.go index a87ee09032..d13410b095 100644 --- a/cmd/authctl/user/unlock.go +++ b/cmd/authctl/user/unlock.go @@ -3,17 +3,20 @@ package user import ( "context" + "github.com/canonical/authd/cmd/authctl/internal/client" + "github.com/canonical/authd/cmd/authctl/internal/completion" "github.com/canonical/authd/internal/proto/authd" "github.com/spf13/cobra" ) // 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), + Use: "unlock ", + Short: "Unlock (enable) a user managed by authd", + Args: cobra.ExactArgs(1), + ValidArgsFunction: completion.Users, RunE: func(cmd *cobra.Command, args []string) error { - client, err := NewUserServiceClient() + client, err := client.NewUserServiceClient() if err != nil { return err } diff --git a/cmd/authctl/user/user.go b/cmd/authctl/user/user.go index b0e371c9b9..109743aa17 100644 --- a/cmd/authctl/user/user.go +++ b/cmd/authctl/user/user.go @@ -2,15 +2,7 @@ package user import ( - "fmt" - "os" - "regexp" - - "github.com/canonical/authd/internal/consts" - "github.com/canonical/authd/internal/proto/authd" "github.com/spf13/cobra" - "google.golang.org/grpc" - "google.golang.org/grpc/credentials/insecure" ) // UserCmd is a command to perform user-related operations. @@ -21,29 +13,8 @@ var UserCmd = &cobra.Command{ 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) + UserCmd.AddCommand(setUIDCmd) } diff --git a/cmd/authctl/user/user_test.go b/cmd/authctl/user/user_test.go index 3d2530bb2c..5010e8cc74 100644 --- a/cmd/authctl/user/user_test.go +++ b/cmd/authctl/user/user_test.go @@ -4,14 +4,9 @@ import ( "fmt" "os" "os/exec" - "path/filepath" - "strings" "testing" "github.com/canonical/authd/internal/testutils" - "github.com/canonical/authd/internal/testutils/golden" - "github.com/stretchr/testify/require" - "google.golang.org/grpc/codes" ) var authctlPath string @@ -37,66 +32,7 @@ func TestUserCommand(t *testing.T) { //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.StartAuthd(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) + testutils.CheckCommand(t, cmd, tc.expectedExitCode) }) } } diff --git a/debian/authd.service.in b/debian/authd.service.in index e8fec012eb..b3729ad741 100644 --- a/debian/authd.service.in +++ b/debian/authd.service.in @@ -86,5 +86,11 @@ SystemCallFilter=@system-service # This makes all files and directories not associated with process management invisible in /proc ProcSubset=pid -# gpasswd requires this specific capability to alter the shadow files -CapabilityBoundingSet=CAP_CHOWN +# CAP_CHOWN: Required by gpasswd to alter the shadow files. +# CAP_DAC_READ_SEARCH: Required by the chown system call to change ownership of +# files not owned by the user. We need this to change the +# ownership of the user's home directory when changing the +# user's UID. +# CAP_SYS_PTRACE: Required by CheckUserBusy used by SetUserID to check if any +# running processes are owned by the UID being modified. +CapabilityBoundingSet=CAP_CHOWN CAP_DAC_READ_SEARCH CAP_SYS_PTRACE diff --git a/debian/control b/debian/control index 425c2b4020..f120a9d486 100644 --- a/debian/control +++ b/debian/control @@ -24,6 +24,7 @@ Build-Depends: debhelper-compat (= 13), pkgconf, protobuf-compiler, systemd-dev, + uidmap , Standards-Version: 4.6.2 XS-Go-Import-Path: github.com/canonical/authd XS-Vendored-Sources-Rust: adler2@2.0.1, aho-corasick@1.1.3, anyhow@1.0.99, async-trait@0.1.89, atomic-waker@1.1.2, autocfg@1.5.0, axum-core@0.5.2, axum@0.8.4, base64@0.22.1, bitflags@2.9.3, bytes@1.10.1, cc@1.2.34, cfg-if@1.0.3, chrono@0.4.41, colored@2.2.0, crc32fast@1.5.0, ctor-proc-macro@0.0.6, ctor@0.5.0, deranged@0.4.0, dtor-proc-macro@0.0.6, dtor@0.1.0, either@1.15.0, equivalent@1.0.2, errno@0.3.13, fastrand@2.3.0, fixedbitset@0.5.7, flate2@1.1.2, fnv@1.0.7, futures-channel@0.3.31, futures-core@0.3.31, futures-sink@0.3.31, futures-task@0.3.31, futures-util@0.3.31, getrandom@0.3.3, h2@0.4.12, hashbrown@0.15.5, heck@0.5.0, hex@0.4.3, hostname@0.4.1, http-body-util@0.1.3, http-body@1.0.1, http@1.3.1, httparse@1.10.1, httpdate@1.0.3, hyper-timeout@0.5.2, hyper-util@0.1.16, hyper@1.7.0, iana-time-zone@0.1.63, indexmap@2.11.0, itertools@0.14.0, itoa@1.0.15, lazy_static@1.5.0, libc@0.2.175, libnss@0.9.0, linux-raw-sys@0.4.15, linux-raw-sys@0.9.4, log@0.4.27, matchit@0.8.4, memchr@2.7.5, mime@0.3.17, miniz_oxide@0.8.9, mio@1.0.4, multimap@0.10.1, num-conv@0.1.0, num-traits@0.2.19, num_threads@0.1.7, once_cell@1.21.3, paste@1.0.15, percent-encoding@2.3.2, petgraph@0.7.1, pin-project-internal@1.1.10, pin-project-lite@0.2.16, pin-project@1.1.10, pin-utils@0.1.0, powerfmt@0.2.0, prettyplease@0.2.37, proc-macro2@1.0.101, procfs-core@0.17.0, procfs@0.17.0, prost-build@0.14.1, prost-derive@0.14.1, prost-types@0.14.1, prost@0.14.1, pulldown-cmark-to-cmark@21.0.0, pulldown-cmark@0.13.0, quote@1.0.40, regex-automata@0.4.10, regex-syntax@0.8.6, regex@1.11.2, rustix@0.38.44, rustix@1.0.8, rustversion@1.0.22, serde@1.0.219, shlex@1.3.0, simple_logger@5.0.0, slab@0.4.11, smallvec@1.15.1, socket2@0.6.0, syn@2.0.106, sync_wrapper@1.0.2, syslog@7.0.0, tempfile@3.21.0, time-core@0.1.4, time-macros@0.2.22, time@0.3.41, tokio-macros@2.5.0, tokio-stream@0.1.17, tokio-util@0.7.16, tokio@1.47.1, tonic-build@0.14.2, tonic-prost-build@0.14.2, tonic-prost@0.14.2, tonic@0.14.2, tower-layer@0.3.3, tower-service@0.3.3, tower@0.4.13, tower@0.5.2, tracing-attributes@0.1.30, tracing-core@0.1.34, tracing@0.1.41, try-lock@0.2.5, unicase@2.8.1, unicode-ident@1.0.18, want@0.3.1 diff --git a/debian/tests/run-tests.sh b/debian/tests/run-tests.sh index 29aeb2df73..0a8f7f12ec 100755 --- a/debian/tests/run-tests.sh +++ b/debian/tests/run-tests.sh @@ -9,4 +9,4 @@ export GOTOOLCHAIN=local PATH=$PATH:$("$(dirname "$0")"/../get-depends-go-bin-path.sh) export PATH -go test -v ./... +go test ./... diff --git a/internal/fileutils/fileutils.go b/internal/fileutils/fileutils.go index 0a54facaba..8d90c12415 100644 --- a/internal/fileutils/fileutils.go +++ b/internal/fileutils/fileutils.go @@ -7,6 +7,7 @@ import ( "io" "os" "path/filepath" + "syscall" "golang.org/x/sys/unix" ) @@ -130,3 +131,48 @@ func LockDir(dir string) (func() error, error) { return unlock, nil } + +// ChownRecursiveFrom changes ownership of files and directories under the +// specified root directory from the current UID/GID (fromUID, fromGID) to the +// new UID/GID (toUID, toGID). +// +// It mirrors the behavior of chown_tree from shadow-utils: +// https://github.com/shadow-maint/shadow/blob/e7ccd3df6845c184d155a2dd573f52d239c94337/lib/chowndir.c#L129-L141 +// +// Symlinks are not followed. +// +// If toUID or toGID is negative, the UID or GID is not changed. +func ChownRecursiveFrom(root string, fromUID, fromGID uint32, toUID, toGID int32) error { + if toUID < 0 && toGID < 0 { + return nil + } + + return filepath.WalkDir(root, func(path string, d os.DirEntry, err error) error { + if err != nil { + return err + } + + info, err := d.Info() + if err != nil { + return err + } + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return fmt.Errorf("failed to get raw stat for %q", path) + } + + if toUID >= 0 && stat.Uid == fromUID { + if err := os.Lchown(path, int(toUID), -1); err != nil { + return fmt.Errorf("failed to change ownership: %w", err) + } + } + + if toGID >= 0 && stat.Gid == fromGID { + if err := os.Lchown(path, -1, int(toGID)); err != nil { + return fmt.Errorf("failed to change group ownership: %w", err) + } + } + + return nil + }) +} diff --git a/internal/fileutils/fileutils_test.go b/internal/fileutils/fileutils_test.go index 49b413290c..56d87e432f 100644 --- a/internal/fileutils/fileutils_test.go +++ b/internal/fileutils/fileutils_test.go @@ -2,13 +2,17 @@ package fileutils_test import ( "errors" + "fmt" "os" + "os/exec" "path/filepath" + "syscall" "testing" "time" "github.com/canonical/authd/internal/fileutils" "github.com/canonical/authd/internal/testutils" + "github.com/canonical/authd/internal/testutils/golden" "github.com/google/uuid" "github.com/stretchr/testify/require" ) @@ -396,3 +400,118 @@ func TestLockDir(t *testing.T) { require.Fail(t, "LockDir should have returned after the first lock was released") } } + +func TestChownRecursiveFrom(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + fromUID uint32 + fromGID uint32 + toUID int32 + toGID int32 + readOnlyFilesystem bool + fileUID uint32 + fileGID uint32 + dirUID uint32 + dirGID uint32 + + wantError bool + wantErrorMatch string + }{ + "Successfully_change_owner": {fromUID: 0, fromGID: 0, toUID: 1, toGID: -1}, + "Successfully_change_group": {fromUID: 0, fromGID: 0, toUID: -1, toGID: 1}, + "Successfully_change_owner_and_group": {fromUID: 0, fromGID: 0, toUID: 1, toGID: 1}, + "Group_not_changed_when_GID_does_not_match": {fromUID: 0, fromGID: 2, toUID: 1, toGID: 1}, + "Owner_not_changed_when_UID_does_not_match": {fromUID: 2, fromGID: 0, toUID: 1, toGID: 1}, + "Change_only_the_file_owner": {fromUID: 2, fromGID: 2, toUID: 1, toGID: 1, fileUID: 2}, + "Change_only_the_file_group": {fromUID: 2, fromGID: 2, toUID: 1, toGID: 1, fileGID: 2}, + "Change_only_the_file_owner_and_group": {fromUID: 2, fromGID: 2, toUID: 1, toGID: 1, fileUID: 2, fileGID: 2}, + "Change_only_the_directory_owner": {fromUID: 2, fromGID: 2, toUID: 1, toGID: 1, dirUID: 2}, + "Change_only_the_directory_group": {fromUID: 2, fromGID: 2, toUID: 1, toGID: 1, dirGID: 2}, + "Change_only_the_directory_owner_and_group": {fromUID: 2, fromGID: 2, toUID: 1, toGID: 1, dirUID: 2, dirGID: 2}, + "No_change_if_ownership_is_same_as_current": {fromUID: 2, fromGID: 2, toUID: 0, toGID: 0}, + "No_change_if_no_file_matches_fromUID": {fromUID: 1, toUID: 0, toGID: 0}, + "No_change_if_no_file_matches_fromGID": {fromGID: 1, toUID: 0, toGID: 0}, + "No_change_if_toUID_and_toGID_are_both_negative": {fromUID: 0, fromGID: 0, toUID: -1, toGID: -1}, + + "Error_when_filesystem_is_read_only": { + toUID: 1, readOnlyFilesystem: true, wantError: true, wantErrorMatch: "read-only file system", + }, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if !testutils.RunningInBubblewrap() { + testutils.RunTestInBubbleWrap(t) + return + } + + tempDir := testutils.TempDir(t) + targetDir := filepath.Join(tempDir, "dir") + subDir := filepath.Join(targetDir, "subdir") + filePath := filepath.Join(subDir, "file") + err := os.MkdirAll(subDir, 0o700) + require.NoError(t, err) + err = fileutils.Touch(filePath) + require.NoError(t, err) + err = os.Chown(targetDir, int(tc.dirUID), int(tc.dirGID)) + require.NoError(t, err) + err = os.Chown(subDir, int(tc.dirUID), int(tc.dirGID)) + require.NoError(t, err) + err = os.Chown(filePath, int(tc.fileUID), int(tc.fileGID)) + require.NoError(t, err) + + symlinkTarget := filepath.Join(tempDir, "symlink_target") + err = fileutils.Touch(symlinkTarget) + require.NoError(t, err) + symlink := filepath.Join(targetDir, "symlink") + err = os.Symlink(symlinkTarget, symlink) + require.NoError(t, err) + + if tc.readOnlyFilesystem { + //nolint:gosec // G204 it's safe to use exec.Command with a variable here + cmd := exec.Command("mount", "--read-only", "-t", "tmpfs", "tmpfs", targetDir) + cmd.Stderr = os.Stderr + err = cmd.Run() + require.NoError(t, err) + defer func() { + //nolint:gosec // G204 it's safe to use exec.Command with a variable here + cmd := exec.Command("umount", targetDir) + cmd.Stderr = os.Stderr + _ = cmd.Run() + }() + } + + err = fileutils.ChownRecursiveFrom(targetDir, tc.fromUID, tc.fromGID, tc.toUID, tc.toGID) + t.Logf("ChownRecursiveFrom error: %v", err) + if tc.wantError { + require.Error(t, err) + require.Contains(t, err.Error(), tc.wantErrorMatch) + return + } + require.NoError(t, err) + + // The symlink target should not be changed + fileInfo, err := os.Stat(symlinkTarget) + require.NoError(t, err) + stat, ok := fileInfo.Sys().(*syscall.Stat_t) + require.True(t, ok, "File should have a syscall.Stat_t") + require.Equal(t, uint32(0), stat.Uid, "Symlink target UID should not have changed") + require.Equal(t, uint32(0), stat.Gid, "Symlink target GID should not have changed") + + // Check ownership + var s string + for _, f := range []string{targetDir, subDir, filePath} { + fileInfo, err := os.Stat(f) + require.NoError(t, err) + stat, ok := fileInfo.Sys().(*syscall.Stat_t) + require.True(t, ok, "File should have a syscall.Stat_t") + relPath, err := filepath.Rel(tempDir, f) + require.NoError(t, err) + s += fmt.Sprintf("%s: %d:%d\n", relPath, stat.Uid, stat.Gid) + } + golden.CheckOrUpdate(t, s) + }) + } +} diff --git a/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_directory_group b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_directory_group new file mode 100644 index 0000000000..7608bf62c6 --- /dev/null +++ b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_directory_group @@ -0,0 +1,3 @@ +dir: 0:1 +dir/subdir: 0:1 +dir/subdir/file: 0:0 diff --git a/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_directory_owner b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_directory_owner new file mode 100644 index 0000000000..17fe7d57a2 --- /dev/null +++ b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_directory_owner @@ -0,0 +1,3 @@ +dir: 1:0 +dir/subdir: 1:0 +dir/subdir/file: 0:0 diff --git a/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_directory_owner_and_group b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_directory_owner_and_group new file mode 100644 index 0000000000..83296d7a12 --- /dev/null +++ b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_directory_owner_and_group @@ -0,0 +1,3 @@ +dir: 1:1 +dir/subdir: 1:1 +dir/subdir/file: 0:0 diff --git a/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_file_group b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_file_group new file mode 100644 index 0000000000..aaf51617f1 --- /dev/null +++ b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_file_group @@ -0,0 +1,3 @@ +dir: 0:0 +dir/subdir: 0:0 +dir/subdir/file: 0:1 diff --git a/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_file_owner b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_file_owner new file mode 100644 index 0000000000..b86045e63f --- /dev/null +++ b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_file_owner @@ -0,0 +1,3 @@ +dir: 0:0 +dir/subdir: 0:0 +dir/subdir/file: 1:0 diff --git a/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_file_owner_and_group b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_file_owner_and_group new file mode 100644 index 0000000000..5f56fccb03 --- /dev/null +++ b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_file_owner_and_group @@ -0,0 +1,3 @@ +dir: 0:0 +dir/subdir: 0:0 +dir/subdir/file: 1:1 diff --git a/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Group_not_changed_when_GID_does_not_match b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Group_not_changed_when_GID_does_not_match new file mode 100644 index 0000000000..c30b0b1127 --- /dev/null +++ b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Group_not_changed_when_GID_does_not_match @@ -0,0 +1,3 @@ +dir: 1:0 +dir/subdir: 1:0 +dir/subdir/file: 1:0 diff --git a/internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_no_file_matches_fromGID b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_no_file_matches_fromGID new file mode 100644 index 0000000000..54ea53452b --- /dev/null +++ b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_no_file_matches_fromGID @@ -0,0 +1,3 @@ +dir: 0:0 +dir/subdir: 0:0 +dir/subdir/file: 0:0 diff --git a/internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_no_file_matches_fromUID b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_no_file_matches_fromUID new file mode 100644 index 0000000000..54ea53452b --- /dev/null +++ b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_no_file_matches_fromUID @@ -0,0 +1,3 @@ +dir: 0:0 +dir/subdir: 0:0 +dir/subdir/file: 0:0 diff --git a/internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_ownership_is_same_as_current b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_ownership_is_same_as_current new file mode 100644 index 0000000000..54ea53452b --- /dev/null +++ b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_ownership_is_same_as_current @@ -0,0 +1,3 @@ +dir: 0:0 +dir/subdir: 0:0 +dir/subdir/file: 0:0 diff --git a/internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_toUID_and_toGID_are_both_negative b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_toUID_and_toGID_are_both_negative new file mode 100644 index 0000000000..54ea53452b --- /dev/null +++ b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_toUID_and_toGID_are_both_negative @@ -0,0 +1,3 @@ +dir: 0:0 +dir/subdir: 0:0 +dir/subdir/file: 0:0 diff --git a/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Owner_not_changed_when_UID_does_not_match b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Owner_not_changed_when_UID_does_not_match new file mode 100644 index 0000000000..82dc505efc --- /dev/null +++ b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Owner_not_changed_when_UID_does_not_match @@ -0,0 +1,3 @@ +dir: 0:1 +dir/subdir: 0:1 +dir/subdir/file: 0:1 diff --git a/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Successfully_change_group b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Successfully_change_group new file mode 100644 index 0000000000..82dc505efc --- /dev/null +++ b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Successfully_change_group @@ -0,0 +1,3 @@ +dir: 0:1 +dir/subdir: 0:1 +dir/subdir/file: 0:1 diff --git a/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Successfully_change_owner b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Successfully_change_owner new file mode 100644 index 0000000000..c30b0b1127 --- /dev/null +++ b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Successfully_change_owner @@ -0,0 +1,3 @@ +dir: 1:0 +dir/subdir: 1:0 +dir/subdir/file: 1:0 diff --git a/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Successfully_change_owner_and_group b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Successfully_change_owner_and_group new file mode 100644 index 0000000000..fe8a889e6b --- /dev/null +++ b/internal/fileutils/testdata/golden/TestChownRecursiveFrom/Successfully_change_owner_and_group @@ -0,0 +1,3 @@ +dir: 1:1 +dir/subdir: 1:1 +dir/subdir/file: 1:1 diff --git a/internal/proto/authd/authd.pb.go b/internal/proto/authd/authd.pb.go index 66bcb4bd03..2713be4297 100644 --- a/internal/proto/authd/authd.pb.go +++ b/internal/proto/authd/authd.pb.go @@ -1169,6 +1169,218 @@ func (x *GetGroupByIDRequest) GetId() uint32 { return 0 } +type SetUserIDRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Id uint32 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` + // The language to use for any warnings returned. + // Note: This is currently not implemented and warnings are always in English. + Lang string `protobuf:"bytes,3,opt,name=lang,proto3" json:"lang,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetUserIDRequest) Reset() { + *x = SetUserIDRequest{} + mi := &file_authd_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetUserIDRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetUserIDRequest) ProtoMessage() {} + +func (x *SetUserIDRequest) ProtoReflect() protoreflect.Message { + mi := &file_authd_proto_msgTypes[22] + 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 SetUserIDRequest.ProtoReflect.Descriptor instead. +func (*SetUserIDRequest) Descriptor() ([]byte, []int) { + return file_authd_proto_rawDescGZIP(), []int{22} +} + +func (x *SetUserIDRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *SetUserIDRequest) GetId() uint32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *SetUserIDRequest) GetLang() string { + if x != nil { + return x.Lang + } + return "" +} + +type SetUserIDResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Warnings []string `protobuf:"bytes,1,rep,name=warnings,proto3" json:"warnings,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetUserIDResponse) Reset() { + *x = SetUserIDResponse{} + mi := &file_authd_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetUserIDResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetUserIDResponse) ProtoMessage() {} + +func (x *SetUserIDResponse) ProtoReflect() protoreflect.Message { + mi := &file_authd_proto_msgTypes[23] + 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 SetUserIDResponse.ProtoReflect.Descriptor instead. +func (*SetUserIDResponse) Descriptor() ([]byte, []int) { + return file_authd_proto_rawDescGZIP(), []int{23} +} + +func (x *SetUserIDResponse) GetWarnings() []string { + if x != nil { + return x.Warnings + } + return nil +} + +type SetGroupIDRequest struct { + state protoimpl.MessageState `protogen:"open.v1"` + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + Id uint32 `protobuf:"varint,2,opt,name=id,proto3" json:"id,omitempty"` + // The language to use for any warnings returned. + // Note: This is currently not implemented and warnings are always in English. + Lang string `protobuf:"bytes,3,opt,name=lang,proto3" json:"lang,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetGroupIDRequest) Reset() { + *x = SetGroupIDRequest{} + mi := &file_authd_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetGroupIDRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetGroupIDRequest) ProtoMessage() {} + +func (x *SetGroupIDRequest) ProtoReflect() protoreflect.Message { + mi := &file_authd_proto_msgTypes[24] + 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 SetGroupIDRequest.ProtoReflect.Descriptor instead. +func (*SetGroupIDRequest) Descriptor() ([]byte, []int) { + return file_authd_proto_rawDescGZIP(), []int{24} +} + +func (x *SetGroupIDRequest) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *SetGroupIDRequest) GetId() uint32 { + if x != nil { + return x.Id + } + return 0 +} + +func (x *SetGroupIDRequest) GetLang() string { + if x != nil { + return x.Lang + } + return "" +} + +type SetGroupIDResponse struct { + state protoimpl.MessageState `protogen:"open.v1"` + Warnings []string `protobuf:"bytes,1,rep,name=warnings,proto3" json:"warnings,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache +} + +func (x *SetGroupIDResponse) Reset() { + *x = SetGroupIDResponse{} + mi := &file_authd_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) +} + +func (x *SetGroupIDResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SetGroupIDResponse) ProtoMessage() {} + +func (x *SetGroupIDResponse) ProtoReflect() protoreflect.Message { + mi := &file_authd_proto_msgTypes[25] + 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 SetGroupIDResponse.ProtoReflect.Descriptor instead. +func (*SetGroupIDResponse) Descriptor() ([]byte, []int) { + return file_authd_proto_rawDescGZIP(), []int{25} +} + +func (x *SetGroupIDResponse) GetWarnings() []string { + if x != nil { + return x.Warnings + } + return nil +} + type User struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -1183,7 +1395,7 @@ type User struct { func (x *User) Reset() { *x = User{} - mi := &file_authd_proto_msgTypes[22] + mi := &file_authd_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1195,7 +1407,7 @@ func (x *User) String() string { func (*User) ProtoMessage() {} func (x *User) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[22] + mi := &file_authd_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1208,7 +1420,7 @@ func (x *User) ProtoReflect() protoreflect.Message { // Deprecated: Use User.ProtoReflect.Descriptor instead. func (*User) Descriptor() ([]byte, []int) { - return file_authd_proto_rawDescGZIP(), []int{22} + return file_authd_proto_rawDescGZIP(), []int{26} } func (x *User) GetName() string { @@ -1262,7 +1474,7 @@ type Users struct { func (x *Users) Reset() { *x = Users{} - mi := &file_authd_proto_msgTypes[23] + mi := &file_authd_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1274,7 +1486,7 @@ func (x *Users) String() string { func (*Users) ProtoMessage() {} func (x *Users) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[23] + mi := &file_authd_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1287,7 +1499,7 @@ func (x *Users) ProtoReflect() protoreflect.Message { // Deprecated: Use Users.ProtoReflect.Descriptor instead. func (*Users) Descriptor() ([]byte, []int) { - return file_authd_proto_rawDescGZIP(), []int{23} + return file_authd_proto_rawDescGZIP(), []int{27} } func (x *Users) GetUsers() []*User { @@ -1310,7 +1522,7 @@ type Group struct { func (x *Group) Reset() { *x = Group{} - mi := &file_authd_proto_msgTypes[24] + mi := &file_authd_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1322,7 +1534,7 @@ func (x *Group) String() string { func (*Group) ProtoMessage() {} func (x *Group) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[24] + mi := &file_authd_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1335,7 +1547,7 @@ func (x *Group) ProtoReflect() protoreflect.Message { // Deprecated: Use Group.ProtoReflect.Descriptor instead. func (*Group) Descriptor() ([]byte, []int) { - return file_authd_proto_rawDescGZIP(), []int{24} + return file_authd_proto_rawDescGZIP(), []int{28} } func (x *Group) GetName() string { @@ -1375,7 +1587,7 @@ type Groups struct { func (x *Groups) Reset() { *x = Groups{} - mi := &file_authd_proto_msgTypes[25] + mi := &file_authd_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1387,7 +1599,7 @@ func (x *Groups) String() string { func (*Groups) ProtoMessage() {} func (x *Groups) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[25] + mi := &file_authd_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1400,7 +1612,7 @@ func (x *Groups) ProtoReflect() protoreflect.Message { // Deprecated: Use Groups.ProtoReflect.Descriptor instead. func (*Groups) Descriptor() ([]byte, []int) { - return file_authd_proto_rawDescGZIP(), []int{25} + return file_authd_proto_rawDescGZIP(), []int{29} } func (x *Groups) GetGroups() []*Group { @@ -1421,7 +1633,7 @@ type ABResponse_BrokerInfo struct { func (x *ABResponse_BrokerInfo) Reset() { *x = ABResponse_BrokerInfo{} - mi := &file_authd_proto_msgTypes[26] + mi := &file_authd_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1433,7 +1645,7 @@ func (x *ABResponse_BrokerInfo) String() string { func (*ABResponse_BrokerInfo) ProtoMessage() {} func (x *ABResponse_BrokerInfo) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[26] + mi := &file_authd_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1480,7 +1692,7 @@ type GAMResponse_AuthenticationMode struct { func (x *GAMResponse_AuthenticationMode) Reset() { *x = GAMResponse_AuthenticationMode{} - mi := &file_authd_proto_msgTypes[27] + mi := &file_authd_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1492,7 +1704,7 @@ func (x *GAMResponse_AuthenticationMode) String() string { func (*GAMResponse_AuthenticationMode) ProtoMessage() {} func (x *GAMResponse_AuthenticationMode) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[27] + mi := &file_authd_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1537,7 +1749,7 @@ type IARequest_AuthenticationData struct { func (x *IARequest_AuthenticationData) Reset() { *x = IARequest_AuthenticationData{} - mi := &file_authd_proto_msgTypes[28] + mi := &file_authd_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1549,7 +1761,7 @@ func (x *IARequest_AuthenticationData) String() string { func (*IARequest_AuthenticationData) ProtoMessage() {} func (x *IARequest_AuthenticationData) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[28] + mi := &file_authd_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1736,7 +1948,19 @@ const file_authd_proto_rawDesc = "" + "\x15GetGroupByNameRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\"%\n" + "\x13GetGroupByIDRequest\x12\x0e\n" + - "\x02id\x18\x01 \x01(\rR\x02id\"\x84\x01\n" + + "\x02id\x18\x01 \x01(\rR\x02id\"J\n" + + "\x10SetUserIDRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x0e\n" + + "\x02id\x18\x02 \x01(\rR\x02id\x12\x12\n" + + "\x04lang\x18\x03 \x01(\tR\x04lang\"/\n" + + "\x11SetUserIDResponse\x12\x1a\n" + + "\bwarnings\x18\x01 \x03(\tR\bwarnings\"K\n" + + "\x11SetGroupIDRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x0e\n" + + "\x02id\x18\x02 \x01(\rR\x02id\x12\x12\n" + + "\x04lang\x18\x03 \x01(\tR\x04lang\"0\n" + + "\x12SetGroupIDResponse\x12\x1a\n" + + "\bwarnings\x18\x01 \x03(\tR\bwarnings\"\x84\x01\n" + "\x04User\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x10\n" + "\x03uid\x18\x02 \x01(\rR\x03uid\x12\x10\n" + @@ -1766,14 +1990,17 @@ 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\xb3\x03\n" + + "\x17SetDefaultBrokerForUser\x12\x13.authd.SDBFURequest\x1a\f.authd.Empty2\xb6\x04\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\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" + + "UnlockUser\x12\x18.authd.UnlockUserRequest\x1a\f.authd.Empty\x12>\n" + + "\tSetUserID\x12\x17.authd.SetUserIDRequest\x1a\x18.authd.SetUserIDResponse\x12A\n" + + "\n" + + "SetGroupID\x12\x18.authd.SetGroupIDRequest\x1a\x19.authd.SetGroupIDResponse\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" + @@ -1792,7 +2019,7 @@ func file_authd_proto_rawDescGZIP() []byte { } var file_authd_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_authd_proto_msgTypes = make([]protoimpl.MessageInfo, 29) +var file_authd_proto_msgTypes = make([]protoimpl.MessageInfo, 33) var file_authd_proto_goTypes = []any{ (SessionMode)(0), // 0: authd.SessionMode (*Empty)(nil), // 1: authd.Empty @@ -1817,23 +2044,27 @@ var file_authd_proto_goTypes = []any{ (*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 + (*SetUserIDRequest)(nil), // 23: authd.SetUserIDRequest + (*SetUserIDResponse)(nil), // 24: authd.SetUserIDResponse + (*SetGroupIDRequest)(nil), // 25: authd.SetGroupIDRequest + (*SetGroupIDResponse)(nil), // 26: authd.SetGroupIDResponse + (*User)(nil), // 27: authd.User + (*Users)(nil), // 28: authd.Users + (*Group)(nil), // 29: authd.Group + (*Groups)(nil), // 30: authd.Groups + (*ABResponse_BrokerInfo)(nil), // 31: authd.ABResponse.BrokerInfo + (*GAMResponse_AuthenticationMode)(nil), // 32: authd.GAMResponse.AuthenticationMode + (*IARequest_AuthenticationData)(nil), // 33: authd.IARequest.AuthenticationData } var file_authd_proto_depIdxs = []int32{ - 27, // 0: authd.ABResponse.brokers_infos:type_name -> authd.ABResponse.BrokerInfo + 31, // 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 - 28, // 3: authd.GAMResponse.authentication_modes:type_name -> authd.GAMResponse.AuthenticationMode + 32, // 3: authd.GAMResponse.authentication_modes:type_name -> authd.GAMResponse.AuthenticationMode 9, // 4: authd.SAMResponse.ui_layout_info:type_name -> authd.UILayout - 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 + 33, // 5: authd.IARequest.authentication_data:type_name -> authd.IARequest.AuthenticationData + 27, // 6: authd.Users.users:type_name -> authd.User + 29, // 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 @@ -1847,27 +2078,31 @@ var file_authd_proto_depIdxs = []int32{ 1, // 18: authd.UserService.ListUsers:input_type -> authd.Empty 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 + 23, // 21: authd.UserService.SetUserID:input_type -> authd.SetUserIDRequest + 25, // 22: authd.UserService.SetGroupID:input_type -> authd.SetGroupIDRequest + 21, // 23: authd.UserService.GetGroupByName:input_type -> authd.GetGroupByNameRequest + 22, // 24: authd.UserService.GetGroupByID:input_type -> authd.GetGroupByIDRequest + 1, // 25: authd.UserService.ListGroups:input_type -> authd.Empty + 4, // 26: authd.PAM.AvailableBrokers:output_type -> authd.ABResponse + 3, // 27: authd.PAM.GetPreviousBroker:output_type -> authd.GPBResponse + 7, // 28: authd.PAM.SelectBroker:output_type -> authd.SBResponse + 10, // 29: authd.PAM.GetAuthenticationModes:output_type -> authd.GAMResponse + 12, // 30: authd.PAM.SelectAuthenticationMode:output_type -> authd.SAMResponse + 14, // 31: authd.PAM.IsAuthenticated:output_type -> authd.IAResponse + 1, // 32: authd.PAM.EndSession:output_type -> authd.Empty + 1, // 33: authd.PAM.SetDefaultBrokerForUser:output_type -> authd.Empty + 27, // 34: authd.UserService.GetUserByName:output_type -> authd.User + 27, // 35: authd.UserService.GetUserByID:output_type -> authd.User + 28, // 36: authd.UserService.ListUsers:output_type -> authd.Users + 1, // 37: authd.UserService.LockUser:output_type -> authd.Empty + 1, // 38: authd.UserService.UnlockUser:output_type -> authd.Empty + 24, // 39: authd.UserService.SetUserID:output_type -> authd.SetUserIDResponse + 26, // 40: authd.UserService.SetGroupID:output_type -> authd.SetGroupIDResponse + 29, // 41: authd.UserService.GetGroupByName:output_type -> authd.Group + 29, // 42: authd.UserService.GetGroupByID:output_type -> authd.Group + 30, // 43: authd.UserService.ListGroups:output_type -> authd.Groups + 26, // [26:44] is the sub-list for method output_type + 8, // [8:26] 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 @@ -1879,8 +2114,8 @@ func file_authd_proto_init() { return } file_authd_proto_msgTypes[8].OneofWrappers = []any{} - file_authd_proto_msgTypes[26].OneofWrappers = []any{} - file_authd_proto_msgTypes[28].OneofWrappers = []any{ + file_authd_proto_msgTypes[30].OneofWrappers = []any{} + file_authd_proto_msgTypes[32].OneofWrappers = []any{ (*IARequest_AuthenticationData_Secret)(nil), (*IARequest_AuthenticationData_Wait)(nil), (*IARequest_AuthenticationData_Skip)(nil), @@ -1892,7 +2127,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: 29, + NumMessages: 33, NumExtensions: 0, NumServices: 2, }, diff --git a/internal/proto/authd/authd.proto b/internal/proto/authd/authd.proto index 8aa3bd072c..08588e64d3 100644 --- a/internal/proto/authd/authd.proto +++ b/internal/proto/authd/authd.proto @@ -135,6 +135,8 @@ service UserService { rpc ListUsers(Empty) returns (Users); rpc LockUser(LockUserRequest) returns (Empty); rpc UnlockUser(UnlockUserRequest) returns (Empty); + rpc SetUserID(SetUserIDRequest) returns (SetUserIDResponse); + rpc SetGroupID(SetGroupIDRequest) returns (SetGroupIDResponse); rpc GetGroupByName(GetGroupByNameRequest) returns (Group); rpc GetGroupByID(GetGroupByIDRequest) returns (Group); @@ -166,6 +168,30 @@ message GetGroupByIDRequest{ uint32 id = 1; } +message SetUserIDRequest { + string name = 1; + uint32 id = 2; + // The language to use for any warnings returned. + // Note: This is currently not implemented and warnings are always in English. + string lang = 3; +} + +message SetUserIDResponse { + repeated string warnings = 1; +} + +message SetGroupIDRequest { + string name = 1; + uint32 id = 2; + // The language to use for any warnings returned. + // Note: This is currently not implemented and warnings are always in English. + string lang = 3; +} + +message SetGroupIDResponse { + repeated string warnings = 1; +} + message User { string name = 1; uint32 uid = 2; diff --git a/internal/proto/authd/authd_grpc.pb.go b/internal/proto/authd/authd_grpc.pb.go index a282188ed1..8685f6fdca 100644 --- a/internal/proto/authd/authd_grpc.pb.go +++ b/internal/proto/authd/authd_grpc.pb.go @@ -392,6 +392,8 @@ const ( UserService_ListUsers_FullMethodName = "/authd.UserService/ListUsers" UserService_LockUser_FullMethodName = "/authd.UserService/LockUser" UserService_UnlockUser_FullMethodName = "/authd.UserService/UnlockUser" + UserService_SetUserID_FullMethodName = "/authd.UserService/SetUserID" + UserService_SetGroupID_FullMethodName = "/authd.UserService/SetGroupID" UserService_GetGroupByName_FullMethodName = "/authd.UserService/GetGroupByName" UserService_GetGroupByID_FullMethodName = "/authd.UserService/GetGroupByID" UserService_ListGroups_FullMethodName = "/authd.UserService/ListGroups" @@ -406,6 +408,8 @@ type UserServiceClient interface { 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) + SetUserID(ctx context.Context, in *SetUserIDRequest, opts ...grpc.CallOption) (*SetUserIDResponse, error) + SetGroupID(ctx context.Context, in *SetGroupIDRequest, opts ...grpc.CallOption) (*SetGroupIDResponse, 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) @@ -469,6 +473,26 @@ func (c *userServiceClient) UnlockUser(ctx context.Context, in *UnlockUserReques return out, nil } +func (c *userServiceClient) SetUserID(ctx context.Context, in *SetUserIDRequest, opts ...grpc.CallOption) (*SetUserIDResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SetUserIDResponse) + err := c.cc.Invoke(ctx, UserService_SetUserID_FullMethodName, in, out, cOpts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *userServiceClient) SetGroupID(ctx context.Context, in *SetGroupIDRequest, opts ...grpc.CallOption) (*SetGroupIDResponse, error) { + cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) + out := new(SetGroupIDResponse) + err := c.cc.Invoke(ctx, UserService_SetGroupID_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) @@ -508,6 +532,8 @@ type UserServiceServer interface { ListUsers(context.Context, *Empty) (*Users, error) LockUser(context.Context, *LockUserRequest) (*Empty, error) UnlockUser(context.Context, *UnlockUserRequest) (*Empty, error) + SetUserID(context.Context, *SetUserIDRequest) (*SetUserIDResponse, error) + SetGroupID(context.Context, *SetGroupIDRequest) (*SetGroupIDResponse, error) GetGroupByName(context.Context, *GetGroupByNameRequest) (*Group, error) GetGroupByID(context.Context, *GetGroupByIDRequest) (*Group, error) ListGroups(context.Context, *Empty) (*Groups, error) @@ -536,6 +562,12 @@ func (UnimplementedUserServiceServer) LockUser(context.Context, *LockUserRequest func (UnimplementedUserServiceServer) UnlockUser(context.Context, *UnlockUserRequest) (*Empty, error) { return nil, status.Error(codes.Unimplemented, "method UnlockUser not implemented") } +func (UnimplementedUserServiceServer) SetUserID(context.Context, *SetUserIDRequest) (*SetUserIDResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SetUserID not implemented") +} +func (UnimplementedUserServiceServer) SetGroupID(context.Context, *SetGroupIDRequest) (*SetGroupIDResponse, error) { + return nil, status.Error(codes.Unimplemented, "method SetGroupID not implemented") +} func (UnimplementedUserServiceServer) GetGroupByName(context.Context, *GetGroupByNameRequest) (*Group, error) { return nil, status.Error(codes.Unimplemented, "method GetGroupByName not implemented") } @@ -656,6 +688,42 @@ func _UserService_UnlockUser_Handler(srv interface{}, ctx context.Context, dec f return interceptor(ctx, in, info, handler) } +func _UserService_SetUserID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetUserIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserServiceServer).SetUserID(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserService_SetUserID_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserServiceServer).SetUserID(ctx, req.(*SetUserIDRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _UserService_SetGroupID_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SetGroupIDRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(UserServiceServer).SetGroupID(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: UserService_SetGroupID_FullMethodName, + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(UserServiceServer).SetGroupID(ctx, req.(*SetGroupIDRequest)) + } + 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 { @@ -737,6 +805,14 @@ var UserService_ServiceDesc = grpc.ServiceDesc{ MethodName: "UnlockUser", Handler: _UserService_UnlockUser_Handler, }, + { + MethodName: "SetUserID", + Handler: _UserService_SetUserID_Handler, + }, + { + MethodName: "SetGroupID", + Handler: _UserService_SetGroupID_Handler, + }, { MethodName: "GetGroupByName", Handler: _UserService_GetGroupByName_Handler, diff --git a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_not_root/IsAuthenticated b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_not_root/IsAuthenticated index 6faba367bb..827eb715d3 100644 --- a/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_not_root/IsAuthenticated +++ b/internal/services/pam/testdata/golden/TestIsAuthenticated/Error_when_not_root/IsAuthenticated @@ -1,4 +1,4 @@ FIRST CALL: access: msg: - err: permission denied: only root can perform this operation + err: only root can perform this operation diff --git a/internal/services/permissions.go b/internal/services/permissions.go index 0f8e0c6973..1c43c232e5 100644 --- a/internal/services/permissions.go +++ b/internal/services/permissions.go @@ -5,16 +5,18 @@ import ( "strings" "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/status" ) func (m Manager) globalPermissions(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (interface{}, error) { if strings.HasPrefix(info.FullMethod, "/authd.PAM/") { if err := m.pamService.CheckGlobalAccess(ctx, info.FullMethod); err != nil { - return nil, err + return nil, status.Error(codes.PermissionDenied, err.Error()) } } else if strings.HasPrefix(info.FullMethod, "/authd.NSS/") { if err := m.userService.CheckGlobalAccess(ctx, info.FullMethod); err != nil { - return nil, err + return nil, status.Error(codes.PermissionDenied, err.Error()) } } diff --git a/internal/services/permissions/permissions.go b/internal/services/permissions/permissions.go index 2df7eb2de3..a2c00eb6d7 100644 --- a/internal/services/permissions/permissions.go +++ b/internal/services/permissions/permissions.go @@ -5,7 +5,6 @@ import ( "context" "errors" - "github.com/canonical/authd/internal/decorate" "google.golang.org/grpc/peer" ) @@ -41,8 +40,6 @@ func New(args ...Option) Manager { // CheckRequestIsFromRoot checks if the current gRPC request is from a root user and returns an error if not. // The pid and uid are extracted from peerCredsInfo in the gRPC context. func (m Manager) CheckRequestIsFromRoot(ctx context.Context) (err error) { - defer decorate.OnError(&err, "permission denied") - p, ok := peer.FromContext(ctx) if !ok { return errors.New("context request doesn't have gRPC peer information") diff --git a/internal/services/permissions/servercreds.go b/internal/services/permissions/servercreds.go index c2cd1ee389..72b3d71d3a 100644 --- a/internal/services/permissions/servercreds.go +++ b/internal/services/permissions/servercreds.go @@ -71,7 +71,7 @@ type peerCredsInfo struct { pid int32 } -// AuthType returns a string encrypting uid and pid of caller. +// AuthType returns a string containing the uid and pid of caller. func (p peerCredsInfo) AuthType() string { return fmt.Sprintf("uid: %d, pid: %d", p.uid, p.pid) } diff --git a/internal/services/testdata/golden/TestRegisterGRPCServices b/internal/services/testdata/golden/TestRegisterGRPCServices index ad43546e3c..871d7f2387 100644 --- a/internal/services/testdata/golden/TestRegisterGRPCServices +++ b/internal/services/testdata/golden/TestRegisterGRPCServices @@ -48,6 +48,12 @@ authd.UserService: - name: LockUser isclientstream: false isserverstream: false + - name: SetGroupID + isclientstream: false + isserverstream: false + - name: SetUserID + isclientstream: false + isserverstream: false - name: UnlockUser isclientstream: false isserverstream: false diff --git a/internal/services/user/user.go b/internal/services/user/user.go index 84861a6521..10ba777cf2 100644 --- a/internal/services/user/user.go +++ b/internal/services/user/user.go @@ -120,7 +120,7 @@ func (s Service) ListUsers(ctx context.Context, req *authd.Empty) (*authd.Users, // 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 + return nil, status.Error(codes.PermissionDenied, err.Error()) } // authd uses lowercase usernames. @@ -140,7 +140,7 @@ func (s Service) LockUser(ctx context.Context, req *authd.LockUserRequest) (*aut // 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 + return nil, status.Error(codes.PermissionDenied, err.Error()) } // authd uses lowercase usernames. @@ -218,6 +218,50 @@ func (s Service) ListGroups(ctx context.Context, req *authd.Empty) (*authd.Group return &res, nil } +// SetUserID sets the UID of a user. +func (s Service) SetUserID(ctx context.Context, req *authd.SetUserIDRequest) (*authd.SetUserIDResponse, error) { + if err := s.permissionManager.CheckRequestIsFromRoot(ctx); err != nil { + return nil, status.Error(codes.PermissionDenied, err.Error()) + } + + // authd uses lowercase usernames. + name := strings.ToLower(req.GetName()) + + if name == "" { + return nil, status.Error(codes.InvalidArgument, "no user name provided") + } + + warnings, err := s.userManager.SetUserID(name, req.GetId()) + if err != nil { + log.Errorf(ctx, "SetUserID: %v", err) + return nil, grpcError(err) + } + + return &authd.SetUserIDResponse{Warnings: warnings}, nil +} + +// SetGroupID sets the GID of a group. +func (s Service) SetGroupID(ctx context.Context, req *authd.SetGroupIDRequest) (*authd.SetGroupIDResponse, error) { + if err := s.permissionManager.CheckRequestIsFromRoot(ctx); err != nil { + return nil, status.Error(codes.PermissionDenied, err.Error()) + } + + // authd uses lowercase group names. + name := strings.ToLower(req.GetName()) + + if name == "" { + return nil, status.Error(codes.InvalidArgument, "no group name provided") + } + + warnings, err := s.userManager.SetGroupID(name, req.GetId()) + if err != nil { + log.Errorf(ctx, "SetGroupID: %v", err) + return nil, grpcError(err) + } + + return &authd.SetGroupIDResponse{Warnings: warnings}, nil +} + // userToProtobuf converts a types.UserEntry to authd.User. func userToProtobuf(u types.UserEntry) *authd.User { return &authd.User{ diff --git a/internal/testlog/testlog.go b/internal/testlog/testlog.go index 53b5babe0c..d075cd529e 100644 --- a/internal/testlog/testlog.go +++ b/internal/testlog/testlog.go @@ -80,9 +80,9 @@ func RunWithTiming(t *testing.T, msg string, cmd *exec.Cmd, options ...RunWithTi duration := time.Since(start) if err != nil { - fmt.Fprintln(w, redSeparatorf("%s failed in %.3fs with %v", msg, duration.Seconds(), err)+"\n") + fmt.Fprint(w, redSeparatorf("%s failed in %.3fs with %v", msg, duration.Seconds(), err)+"\n") } else { - fmt.Fprintln(w, separatorf("%s finished in %.3fs", msg, duration.Seconds())+"\n") + fmt.Fprint(w, separatorf("%s finished in %.3fs", msg, duration.Seconds())+"\n") } return err @@ -98,7 +98,7 @@ func LogCommand(t *testing.T, msg string, cmd *exec.Cmd) { w := testOutput(t) sep := "----------------------------------------" - fmt.Fprintf(w, "\n"+separator(msg)+"command: %s\n%s\nenvironment: %s\n%s\n", cmd.String(), sep, cmd.Env, sep) + fmt.Fprintf(w, "\n"+separator(msg)+"\ncommand: %s\n%s\nenvironment: %s\n%s\n", cmd.String(), sep, cmd.Env, sep) } // LogStartSeparatorf logs a separator to stderr with the given formatted message. @@ -110,7 +110,7 @@ func LogStartSeparatorf(t *testing.T, s string, args ...any) { } w := testOutput(t) - fmt.Fprintln(w, "\n"+separatorf(s, args...)) + fmt.Fprint(w, "\n"+separatorf(s, args...)) } // LogStartSeparator logs a separator to stderr with the given message. @@ -122,7 +122,7 @@ func LogStartSeparator(t *testing.T, args ...any) { } w := testOutput(t) - fmt.Fprintln(w, "\n"+separator(args...)) + fmt.Fprint(w, "\n"+separator(args...)) } // LogEndSeparatorf logs a separator to stderr with the given formatted message. @@ -134,7 +134,7 @@ func LogEndSeparatorf(t *testing.T, s string, args ...any) { } w := testOutput(t) - fmt.Fprintln(w, separatorf(s, args...)+"\n") + fmt.Fprint(w, separatorf(s, args...)+"\n\n") } // LogEndSeparator logs a separator to stderr with the given message. @@ -146,21 +146,45 @@ func LogEndSeparator(t *testing.T, args ...any) { } w := testOutput(t) - fmt.Fprintln(w, separator(args...)+"\n") + fmt.Fprint(w, separator(args...)+"\n\n") +} + +// LogRedEndSeparatorf logs a separator to stderr with the given formatted message in red. +// +//nolint:thelper // we do call t.Helper() if t is not nil +func LogRedEndSeparatorf(t *testing.T, s string, args ...any) { + if t != nil { + t.Helper() + } + w := testOutput(t) + + fmt.Fprint(w, redSeparatorf(s, args...)+"\n\n") +} + +// LogRedEndSeparator logs a separator to stderr with the given message in red. +// +//nolint:thelper // we do call t.Helper() if t is not nil +func LogRedEndSeparator(t *testing.T, args ...any) { + if t != nil { + t.Helper() + } + w := testOutput(t) + + fmt.Fprint(w, redSeparatorf("%s", fmt.Sprint(args...))+"\n\n") } // separatorf returns a formatted separator string for logging purposes. func separatorf(s string, args ...any) string { - return highCyan("===== " + fmt.Sprintf(s, args...) + " =====\n") + return highCyan("===== " + fmt.Sprintf(s, args...) + " =====") } // separator returns a separator string for logging purposes. func separator(args ...any) string { - return highCyan("===== " + fmt.Sprint(args...) + " =====\n") + return highCyan("===== " + fmt.Sprint(args...) + " =====") } func redSeparatorf(s string, args ...any) string { - return highRed("===== " + fmt.Sprintf(s, args...) + " =====\n") + return highRed("===== " + fmt.Sprintf(s, args...) + " =====") } // highCyan returns a string with the given text in high-intensity cyan color for terminal output. diff --git a/internal/testutils/args.go b/internal/testutils/args.go index e8d83039c3..a1564d1541 100644 --- a/internal/testutils/args.go +++ b/internal/testutils/args.go @@ -131,3 +131,9 @@ var IsDebianPackageBuild = sync.OnceValue(func() bool { _, ok := os.LookupEnv("DEB_BUILD_ARCH") return ok }) + +// IsAutoPkgTest returns true if the tests are running in an autopkgtest environment. +var IsAutoPkgTest = sync.OnceValue(func() bool { + _, ok := os.LookupEnv("AUTOPKGTEST_TEST_ARCH") + return ok +}) diff --git a/internal/testutils/authctl.go b/internal/testutils/authctl.go index 8ede48db35..feaf55e4a6 100644 --- a/internal/testutils/authctl.go +++ b/internal/testutils/authctl.go @@ -5,6 +5,8 @@ import ( "os" "os/exec" "path/filepath" + + "github.com/canonical/authd/internal/testlog" ) // BuildAuthctl builds the authctl binary in a temporary directory for testing purposes. @@ -21,10 +23,8 @@ func BuildAuthctl() (binaryPath string, cleanup func(), err error) { 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 { + if err := testlog.RunWithTiming(nil, "Building authctl", cmd); err != nil { cleanup() - fmt.Printf("Command output:\n%s\n", output) return "", nil, fmt.Errorf("failed to build authctl: %w", err) } diff --git a/internal/testutils/bubblewrap.go b/internal/testutils/bubblewrap.go index ec39e701cb..317e706c43 100644 --- a/internal/testutils/bubblewrap.go +++ b/internal/testutils/bubblewrap.go @@ -9,6 +9,8 @@ import ( "testing" "github.com/canonical/authd/internal/fileutils" + "github.com/canonical/authd/internal/testlog" + "github.com/canonical/authd/internal/testutils/golden" "github.com/stretchr/testify/require" ) @@ -18,6 +20,9 @@ var ( bubbleWrapNeedsSudoOnce sync.Once bubbleWrapNeedsSudo bool + + copyBwrapOnce sync.Once + copiedBwrapPath string ) const bubbleWrapTestEnvVar = "BUBBLEWRAP_TEST" @@ -27,80 +32,52 @@ func RunningInBubblewrap() bool { return os.Getenv(bubbleWrapTestEnvVar) == "1" } -// SkipIfCannotRunBubbleWrap checks whether we can run tests running in bubblewrap or -// skip the tests otherwise. -func SkipIfCannotRunBubbleWrap(t *testing.T) { +func canRunBubblewrap(t *testing.T) bool { t.Helper() if os.Geteuid() == 0 { t.Log("Running as EUID 0") - return + return true } bubbleWrapSupportsUnprivilegedNamespacesOnce.Do(func() { bubbleWrapSupportsUnprivilegedNamespaces = canUseUnprivilegedUserNamespaces(t) }) if bubbleWrapSupportsUnprivilegedNamespaces { - return + return true } bubbleWrapNeedsSudoOnce.Do(func() { - bubbleWrapNeedsSudo = canUseSudoNonInteractively(t) + bubbleWrapNeedsSudo = canUseBwrapWithSudoNonInteractively(t) }) - if bubbleWrapNeedsSudo { - return - } - - t.Skip("Skipping test: requires root privileges or unprivileged user namespaces") + return bubbleWrapNeedsSudo } // RunTestInBubbleWrap runs the given test in bubblewrap. func RunTestInBubbleWrap(t *testing.T, args ...string) { t.Helper() - SkipIfCannotRunBubbleWrap(t) + RequireBubblewrap(t) - testCommand := []string{os.Args[0], "-test.run", "^" + t.Name() + "$"} - if testing.Verbose() { - testCommand = append(testCommand, "-test.v") - } - if c := CoverDirForTests(); c != "" { - testCommand = append(testCommand, fmt.Sprintf("-test.gocoverdir=%s", c)) - } - args = append(args, testCommand...) - - t.Logf("Running %s in bubblewrap", t.Name()) - err := runInBubbleWrap(t, bubbleWrapNeedsSudo, "", nil, args...) - if err != nil { - t.Fatalf("Running %s in bubblewrap failed: %v", t.Name(), err) - } -} - -func runInBubbleWrap(t *testing.T, withSudo bool, testDataPath string, env []string, args ...string) error { - t.Helper() - - cmd := exec.Command("bwrap") - cmd.Env = AppendCovEnv(os.Environ()) - cmd.Env = append(cmd.Env, env...) - cmd.Env = append(cmd.Env, bubbleWrapTestEnvVar+"=1") + etcDir := filepath.Join(TempDir(t), "etc") + err := os.MkdirAll(etcDir, 0700) + require.NoError(t, err, "Setup: could not create etc dir") - if withSudo { - cmd.Args = append([]string{"sudo"}, cmd.Args...) + // Copy files needed to create users and groups inside bubblewrap. + for _, f := range []string{"passwd", "group", "subgid"} { + err := fileutils.CopyFile("/etc/"+f, filepath.Join(etcDir, f)) + require.NoError(t, err, "Setup: Copying /etc/%s to %s failed", f, etcDir) } - if testDataPath == "" { - testDataPath = TempDir(t) + env := []string{ + "PATH=" + MinimalPathEnv, + bubbleWrapTestEnvVar + "=1", } + env = AppendCovEnv(env) - etcDir := filepath.Join(testDataPath, "etc") - err := os.MkdirAll(etcDir, 0700) - require.NoError(t, err, "Impossible to create /etc") + cmd := bubbleWrapCommand(t, env, bubbleWrapNeedsSudo) cmd.Args = append(cmd.Args, - "--ro-bind", "/", "/", - "--dev", "/dev", - "--bind", os.TempDir(), os.TempDir(), - "--bind", testDataPath, testDataPath, "--bind", etcDir, "/etc", // Bind relevant etc files. We go manual here, since there's no @@ -111,77 +88,180 @@ func runInBubbleWrap(t *testing.T, withSudo bool, testDataPath string, env []str "--ro-bind", "/etc/localtime", "/etc/localtime", "--ro-bind", "/etc/login.defs", "/etc/login.defs", "--ro-bind", "/etc/nsswitch.conf", "/etc/nsswitch.conf", - "--ro-bind", "/etc/passwd", "/etc/passwd", - "--ro-bind", "/etc/shadow", "/etc/shadow", - "--ro-bind", "/etc/subgid", "/etc/subgid", "--ro-bind", "/etc/sudo.conf", "/etc/sudo.conf", "--ro-bind", "/etc/sudoers", "/etc/sudoers", "--ro-bind-try", "/etc/timezone", "/etc/timezone", "--ro-bind", "/etc/pam.d", "/etc/pam.d", "--ro-bind", "/etc/security", "/etc/security", + + // Bind the test binary itself so that it can be run in bubblewrap. + "--bind", os.Args[0], os.Args[0], ) - replicateHostFile := func(file string) { - require.NotContains(t, cmd.Args, file, - "Setup: %q should not be managed by bwrap", file) - dst := filepath.Join(testDataPath, file) - err := fileutils.CopyFile(file, dst) - require.NoError(t, err, "Setup: Copying %q to %q failed", file, dst) + if coverDir := CoverDirForTests(); coverDir != "" { + cmd.Args = append(cmd.Args, "--bind", coverDir, coverDir) } - // These are the files that we replicate in the bwrap environment and that - // can be safely modified or mocked in the test. - // Adapt this as needed, ensuring these files are not bound. - for _, f := range []string{ - "/etc/group", - } { - replicateHostFile(f) + goldenDir := golden.Dir(t) + exists, err := fileutils.FileExists(goldenDir) + require.NoError(t, err, "Setup: could not check if golden dir exists") + if exists && golden.UpdateEnabled() { + // Bind the golden directory read-write so that the tests can update it. + cmd.Args = append(cmd.Args, "--bind", goldenDir, goldenDir) } + cmd.Args = append(cmd.Args, args...) - if coverDir := CoverDirForTests(); coverDir != "" { - cmd.Args = append(cmd.Args, "--bind", coverDir, coverDir) + testCommand := []string{os.Args[0], "-test.run", "^" + t.Name() + "$"} + if testing.Verbose() { + testCommand = append(testCommand, "-test.v") + } + if c := CoverDirForTests(); c != "" { + testCommand = append(testCommand, fmt.Sprintf("-test.gocoverdir=%s", c)) } + cmd.Args = append(cmd.Args, testCommand...) - if os.Geteuid() != 0 && !withSudo { - cmd.Args = append(cmd.Args, "--unshare-user", "--uid", "0") + testlog.LogCommand(t, fmt.Sprintf("Running %s in bubblewrap", t.Name()), cmd) + err = cmd.Run() + if err != nil { + testlog.LogEndSeparator(t, fmt.Sprintf("%s in bubblewrap failed", t.Name())) + t.Fatalf("Running %s in bubblewrap failed: %v", t.Name(), err) } + testlog.LogEndSeparator(t, fmt.Sprintf("%s in bubblewrap finished", t.Name())) +} + +// RequireBubblewrap ensures that bubblewrap is available and usable for running tests. +// It skips or fails the test if bubblewrap cannot be used in the current environment. +func RequireBubblewrap(t *testing.T) { + t.Helper() + + if !canRunBubblewrap(t) { + if IsAutoPkgTest() && !IsCI() { + // On launchpad builders, we might not be able to run bubblewrap, + // but we don't want to fail the tests in that case. + t.Skip("Skipping test: cannot run bubblewrap") + } + require.Fail(t, "Cannot run bubblewrap") + } +} + +// BubbleWrapCommand returns a command that runs in bubblewrap. +func BubbleWrapCommand(t *testing.T, env []string) *exec.Cmd { + t.Helper() + + RequireBubblewrap(t) + + return bubbleWrapCommand(t, env, bubbleWrapNeedsSudo) +} + +func bubbleWrapCommand(t *testing.T, env []string, withSudo bool) *exec.Cmd { + t.Helper() + var cmd *exec.Cmd + + // Since 25.10 Ubuntu ships the AppArmor profile /etc/apparmor.d/bwrap-userns-restrict + // which restricts bwrap and causes chown to fail with "Operation not permitted". + // We work around that by copying the bwrap binary to a temporary location so that + // the AppArmor profile is not applied. + copyBwrapOnce.Do(func() { + tempDir, err := os.MkdirTemp("", "authd-bwrap-") + require.NoError(t, err, "Setup: could not create temp dir for bwrap test data") + copiedBwrapPath = filepath.Join(tempDir, "bwrap") + err = fileutils.CopyFile("/usr/bin/bwrap", copiedBwrapPath) + require.NoError(t, err, "Setup: could not copy bubblewrap binary to temp location") + }) + + if withSudo { + t.Log("Running bubblewrap with sudo") + cmd = exec.Command("sudo", env...) + cmd.Args = append(cmd.Args, copiedBwrapPath) + } else { + // To be able to use chown in bubblewrap, we need to run it in a user namespace + // with a uid mapping. Bubblewrap itself only supports mapping a single UID via + // --uid, so we use unshare to create a new user namespace with the desired mapping + // and run bwrap in that. + //nolint:gosec // We're not running untrusted code here. + cmd = exec.Command( + "unshare", + "--user", + "--map-root-user", + "--map-users=auto", + "--map-groups=auto", + copiedBwrapPath, + ) + cmd.Env = env + } + + cmd.Args = append(cmd.Args, + "--ro-bind", "/", "/", + "--dev", "/dev", + "--tmpfs", "/tmp", + ) - cmd.Args = append(cmd.Args, args...) cmd.Stderr = t.Output() cmd.Stdout = t.Output() - t.Log("Running command:", cmd.String()) - return cmd.Run() + return cmd } func canUseUnprivilegedUserNamespaces(t *testing.T) bool { t.Helper() - t.Log("Checking if we can use unprivileged user namespaces") + if IsCI() { + // Try enabling unprivileged user namespaces in the CI. + cmd := exec.Command("sudo", "sysctl", "-w", "kernel.unprivileged_userns_clone=1") + cmd.Stdout = t.Output() + cmd.Stderr = t.Output() + _ = cmd.Run() + + // Set /proc/sys/user/max_user_namespaces to a high value. + cmd = exec.Command("sudo", "sysctl", "-w", "user.max_user_namespaces=100000") + cmd.Stdout = t.Output() + cmd.Stderr = t.Output() + _ = cmd.Run() + } + + // We don't try bubbleWrapCommand directly here, because that uses + // `unshare --map-user` via exec.Command and connects the process's + // stdout and stderr, which causes the command to hang forever if + // unprivileged user namespaces are disabled. We avoid that by first + // checking via `unshare --map-root-user` if unprivileged user namespaces + // are enabled. + cmd := exec.Command("unshare", "--map-root-user", "/bin/true") + cmd.Stdout = t.Output() + cmd.Stderr = t.Output() + testlog.LogCommand(t, "Checking unprivileged user namespaces", cmd) + if err := cmd.Run(); err != nil { + testlog.LogRedEndSeparator(t, "Cannot use unprivileged user namespaces") + return false + } + testlog.LogEndSeparator(t, "Can use unprivileged user namespaces") - if err := runInBubbleWrap(t, false, t.TempDir(), nil, "/bin/true"); err != nil { - t.Logf("Can't use user namespaces: %v", err) + cmd = bubbleWrapCommand(t, nil, false) + cmd.Args = append(cmd.Args, "/bin/true") + testlog.LogCommand(t, "Checking bubblewrap with unprivileged user namespaces", cmd) + if err := cmd.Run(); err != nil { + testlog.LogRedEndSeparator(t, "Cannot use bubblewrap with unprivileged user namespaces") return false } - t.Log("Can use unprivileged user namespaces") + testlog.LogEndSeparator(t, "Can use unprivileged user namespaces") return true } -func canUseSudoNonInteractively(t *testing.T) bool { +func canUseBwrapWithSudoNonInteractively(t *testing.T) bool { t.Helper() - cmd := exec.Command("sudo", "-Nnv") - if out, err := cmd.CombinedOutput(); err != nil { - t.Logf("Can't use sudo non-interactively: %v\n%s", err, out) + if !canUseSudoNonInteractively(t) { return false } - if err := runInBubbleWrap(t, true, t.TempDir(), nil, "/bin/true"); err != nil { - t.Logf("Can't use bubblewrap with sudo: %v", err) + cmd := bubbleWrapCommand(t, nil, true) + cmd.Args = append(cmd.Args, "/bin/true") + testlog.LogCommand(t, "Checking bubblewrap with sudo", cmd) + if err := cmd.Run(); err != nil { + testlog.LogRedEndSeparatorf(t, "Cannot use bubblewrap with sudo: %v", err) return false } - t.Log("Can use sudo non-interactively") + testlog.LogEndSeparator(t, "Can use bubblewrap with sudo") return true } diff --git a/internal/testutils/daemon.go b/internal/testutils/daemon.go index 798e644569..5ddc1d93c7 100644 --- a/internal/testutils/daemon.go +++ b/internal/testutils/daemon.go @@ -296,7 +296,6 @@ func BuildAuthdWithExampleBroker() (execPath string, cleanup func(), err error) 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 err := testlog.RunWithTiming(nil, "Building authd", cmd); err != nil { cleanup() return "", nil, fmt.Errorf("failed to build authd: %v", err) diff --git a/internal/testutils/exec.go b/internal/testutils/exec.go new file mode 100644 index 0000000000..7866b49971 --- /dev/null +++ b/internal/testutils/exec.go @@ -0,0 +1,36 @@ +package testutils + +import ( + "io" + "os/exec" + "path/filepath" + "testing" + + "github.com/canonical/authd/internal/testlog" + "github.com/canonical/authd/internal/testutils/golden" + "github.com/stretchr/testify/require" +) + +// CheckCommand runs the given command and: +// * Checks that it exits with the expected exit code. +// * Checks that the output matches the golden file. +func CheckCommand(t *testing.T, cmd *exec.Cmd, expectedExitCode int) { + t.Helper() + basename := filepath.Base(cmd.Args[0]) + + output := &SyncBuffer{} + cmd.Stdout = io.MultiWriter(t.Output(), output) + cmd.Stderr = io.MultiWriter(t.Output(), output) + testlog.LogCommand(t, "Running "+basename, cmd) + err := cmd.Run() + exitcode := cmd.ProcessState.ExitCode() + testlog.LogEndSeparatorf(t, basename+" finished (exit code %d)", exitcode) + + if expectedExitCode == 0 { + require.NoError(t, err, basename+" failed unexpectedly") + } + + require.Equal(t, expectedExitCode, exitcode, "Unexpected exit code") + + golden.CheckOrUpdate(t, output.String()) +} diff --git a/internal/testutils/golden/golden.go b/internal/testutils/golden/golden.go index 8f33392678..0bbc4dfbf2 100644 --- a/internal/testutils/golden/golden.go +++ b/internal/testutils/golden/golden.go @@ -157,15 +157,21 @@ func CheckValidGoldenFileName(t *testing.T, name string) { func Path(t *testing.T) string { t.Helper() - cwd, err := os.Getwd() - require.NoError(t, err, "Cannot get current working directory") - - parts := strings.Split(t.Name(), "/") - for _, part := range parts { + for _, part := range strings.Split(t.Name(), "/") { CheckValidGoldenFileName(t, part) } - return filepath.Join(append([]string{cwd, "testdata", "golden"}, parts...)...) + return filepath.Join(Dir(t), t.Name()) +} + +// Dir returns the golden directory for the provided test. +func Dir(t *testing.T) string { + t.Helper() + + cwd, err := os.Getwd() + require.NoError(t, err, "Cannot get current working directory") + + return filepath.Join(cwd, "testdata", "golden") } // runDelta pipes the unified diff through the `delta` command for word-level diff and coloring. diff --git a/internal/testutils/path.go b/internal/testutils/path.go index 5a50597239..088f3a95d4 100644 --- a/internal/testutils/path.go +++ b/internal/testutils/path.go @@ -80,7 +80,17 @@ func TestFamilyPath(t *testing.T) string { func TempDir(t *testing.T) string { t.Helper() - if v := os.Getenv("SKIP_CLEANUP"); v != "" { + skipCleanup := os.Getenv("SKIP_CLEANUP") != "" + + if RunningInBubblewrap() { + // When running in bubblewrap, we don't need to cleanup temporary directories + // because they only exist inside the bubblewrap sandbox anyway, and we don't + // want the tests to fail if the temporary directory cannot be removed for + // some reason. + skipCleanup = true + } + + if skipCleanup { tempDir, err := os.MkdirTemp("", "authd-bwrap-testdata-") require.NoError(t, err, "Setup: could not create temp dir for bwrap test data") return tempDir diff --git a/internal/testutils/root.go b/internal/testutils/root.go new file mode 100644 index 0000000000..26cb85d459 --- /dev/null +++ b/internal/testutils/root.go @@ -0,0 +1,88 @@ +package testutils + +import ( + "fmt" + "os" + "os/exec" + "sync" + "testing" + + "github.com/canonical/authd/internal/testlog" + "github.com/canonical/authd/internal/testutils/golden" +) + +var ( + needsSudoOnce sync.Once + needsSudo bool +) + +// RunningAsRoot returns true if the current process is running as root. +func RunningAsRoot() bool { + return os.Geteuid() == 0 +} + +// SkipIfCannotRunAsRoot checks whether we can run tests as root or skip the tests otherwise. +func SkipIfCannotRunAsRoot(t *testing.T) { + t.Helper() + + if os.Geteuid() == 0 { + t.Log("Running as EUID 0") + return + } + + needsSudoOnce.Do(func() { + needsSudo = canUseSudoNonInteractively(t) + }) + if needsSudo { + return + } + + t.Skip("Skipping test: requires root privileges") +} + +// RunTestAsRoot runs the given test as root. +func RunTestAsRoot(t *testing.T, args ...string) { + t.Helper() + + SkipIfCannotRunAsRoot(t) + + if v := os.Getenv(golden.UpdateGoldenFilesEnv); v != "" { + args = append(args, fmt.Sprintf("%s=%s", golden.UpdateGoldenFilesEnv, v)) + } + + testCommand := []string{os.Args[0], "-test.run", "^" + t.Name() + "$"} + if testing.Verbose() { + testCommand = append(testCommand, "-test.v") + } + if c := CoverDirForTests(); c != "" { + testCommand = append(testCommand, fmt.Sprintf("-test.gocoverdir=%s", c)) + } + args = append(args, testCommand...) + + cmd := exec.Command("sudo", "-n") + cmd.Args = append(cmd.Args, args...) + cmd.Stdout = t.Output() + cmd.Stderr = t.Output() + testlog.LogCommand(t, fmt.Sprintf("Running %s as root", t.Name()), cmd) + err := cmd.Run() + if err != nil { + testlog.LogEndSeparator(t, fmt.Sprintf("%s as root failed", t.Name())) + t.Fatalf("Running %s as root failed: %v", t.Name(), err) + } + testlog.LogEndSeparator(t, fmt.Sprintf("%s as root finished", t.Name())) +} + +func canUseSudoNonInteractively(t *testing.T) bool { + t.Helper() + + cmd := exec.Command("sudo", "-n", "true") + cmd.Stdout = t.Output() + cmd.Stderr = t.Output() + testlog.LogCommand(t, "Checking if we can use sudo non-interactively", cmd) + if err := cmd.Run(); err != nil { + testlog.LogRedEndSeparator(t, "Cannot use sudo non-interactively") + return false + } + testlog.LogEndSeparator(t, "Can use sudo non-interactively") + return true +} diff --git a/internal/users/db/db_test.go b/internal/users/db/db_test.go index dda644fc03..47cce2060f 100644 --- a/internal/users/db/db_test.go +++ b/internal/users/db/db_test.go @@ -866,6 +866,153 @@ func TestUpdateLockedFieldForUser(t *testing.T) { require.Error(t, err, "UpdateLockedFieldForUser for a nonexistent user should return an error") } +func TestSetUserID(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + nonExistentUser bool + uidAlreadyInUse bool + uidAlreadySet bool + + wantErr bool + wantErrType error + wantUnchangedDB bool + }{ + "Set_user_id_for_existing_user": {}, + "No_op_if_uid_is_already_set": {uidAlreadySet: true, wantUnchangedDB: true}, + + "Error_on_nonexistent_user": {nonExistentUser: true, wantErrType: db.NoDataFoundError{}}, + "Error_if_uid_already_in_use": {uidAlreadyInUse: true, wantErr: true}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + m := initDB(t, "multiple_users_and_groups") + + username := "user1" + if tc.nonExistentUser { + username = "nonexistent" + } + + var newUID uint32 = 1234 + if tc.uidAlreadyInUse { + newUID = 2222 + } + if tc.uidAlreadySet { + newUID = 1111 + } + + var oldDBContent string + var err error + if tc.wantUnchangedDB { + oldDBContent, err = db.Z_ForTests_DumpNormalizedYAML(m) + require.NoError(t, err) + } + + err = m.SetUserID(username, newUID) + log.Infof(context.Background(), "SetUserID error: %v", err) + + if tc.wantErrType != nil { + require.ErrorIs(t, err, tc.wantErrType, "SetUserID should return expected error") + return + } + if tc.wantErr { + require.Error(t, err, "SetUserID should return an error but didn't") + return + } + require.NoError(t, err, "SetUserID should not return an error on existing user") + + dbContent, err := db.Z_ForTests_DumpNormalizedYAML(m) + require.NoError(t, err) + + if tc.wantUnchangedDB { + require.Equal(t, oldDBContent, dbContent, "SetUserID should not change the database content") + return + } + + golden.CheckOrUpdate(t, dbContent) + }) + } +} + +func TestSetGroupID(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + nonExistentGroup bool + gidAlreadyInUse bool + gidAlreadySet bool + + wantErr bool + wantErrType error + wantUnchangedDB bool + }{ + "Set_group_id_for_existing_group": {}, + "No_op_if_gid_is_already_set": {gidAlreadySet: true, wantUnchangedDB: true}, + + "Error_on_nonexistent_group": {nonExistentGroup: true, wantErrType: db.NoDataFoundError{}}, + "Error_if_gid_already_in_use": {gidAlreadyInUse: true, wantErr: true}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + m := initDB(t, "multiple_users_and_groups") + + groupName := "group1" + if tc.nonExistentGroup { + groupName = "nonexistent" + } + + var newGID uint32 = 12345 + if tc.gidAlreadyInUse { + newGID = 22222 // gid used by group2 in test data + } + if tc.gidAlreadySet { + newGID = 11111 // current gid of group1 in test data + } + + var oldDBContent string + var err error + if tc.wantUnchangedDB { + oldDBContent, err = db.Z_ForTests_DumpNormalizedYAML(m) + require.NoError(t, err) + } + + users, err := m.SetGroupID(groupName, newGID) + log.Infof(context.Background(), "SetGroupID error: %v", err) + + if tc.wantErrType != nil { + require.ErrorIs(t, err, tc.wantErrType, "SetGroupID should return expected error") + return + } + if tc.wantErr { + require.Error(t, err, "SetGroupID should return an error but didn't") + return + } + require.NoError(t, err, "SetGroupID should not return an error on existing group") + + // Check the returned users list + if tc.gidAlreadySet { + require.Nil(t, users, "SetGroupID should return nil users list if gid was already set") + } else { + require.Len(t, users, 1, "SetGroupID should return a non-empty users list") + } + + dbContent, err := db.Z_ForTests_DumpNormalizedYAML(m) + require.NoError(t, err) + + if tc.wantUnchangedDB { + require.Equal(t, oldDBContent, dbContent, "SetGroupID should not change the database content") + return + } + + golden.CheckOrUpdate(t, dbContent) + }) + } +} + func TestRemoveDb(t *testing.T) { t.Parallel() diff --git a/internal/users/db/testdata/golden/TestSetGroupID/Set_group_id_for_existing_group b/internal/users/db/testdata/golden/TestSetGroupID/Set_group_id_for_existing_group new file mode 100644 index 0000000000..45d9ebc940 --- /dev/null +++ b/internal/users/db/testdata/golden/TestSetGroupID/Set_group_id_for_existing_group @@ -0,0 +1,64 @@ +users: + - name: user1 + uid: 1111 + gid: 12345 + gecos: |- + User1 gecos + On multiple lines + dir: /home/user1 + shell: /bin/bash + broker_id: broker-id + - 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: 12345 + ugid: "12345678" + - name: group2 + gid: 22222 + ugid: "56781234" + - name: group3 + gid: 33333 + ugid: "34567812" + - name: group4 + gid: 44444 + ugid: "45678123" + - name: commongroup + gid: 99999 + ugid: "87654321" +users_to_groups: + - uid: 1111 + gid: 12345 + - 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/db/testdata/golden/TestSetUserID/No_op_if_uid_is_already_set b/internal/users/db/testdata/golden/TestSetUserID/No_op_if_uid_is_already_set new file mode 100644 index 0000000000..9c042d2317 --- /dev/null +++ b/internal/users/db/testdata/golden/TestSetUserID/No_op_if_uid_is_already_set @@ -0,0 +1,64 @@ +users: + - name: user1 + uid: 1111 + gid: 11111 + gecos: |- + User1 gecos + On multiple lines + dir: /home/user1 + shell: /bin/bash + broker_id: broker-id + - 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: group2 + gid: 22222 + ugid: "56781234" + - 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/db/testdata/golden/TestSetUserID/Set_user_id_for_existing_user b/internal/users/db/testdata/golden/TestSetUserID/Set_user_id_for_existing_user new file mode 100644 index 0000000000..d2f3a1e78b --- /dev/null +++ b/internal/users/db/testdata/golden/TestSetUserID/Set_user_id_for_existing_user @@ -0,0 +1,64 @@ +users: + - name: user1 + uid: 1234 + gid: 11111 + gecos: |- + User1 gecos + On multiple lines + dir: /home/user1 + shell: /bin/bash + broker_id: broker-id + - 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: group2 + gid: 22222 + ugid: "56781234" + - name: group3 + gid: 33333 + ugid: "34567812" + - name: group4 + gid: 44444 + ugid: "45678123" + - name: commongroup + gid: 99999 + ugid: "87654321" +users_to_groups: + - uid: 1234 + gid: 11111 + - uid: 1234 + 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/db/update.go b/internal/users/db/update.go index 76de951b66..4b8c01c776 100644 --- a/internal/users/db/update.go +++ b/internal/users/db/update.go @@ -193,3 +193,168 @@ func (m *Manager) UpdateLockedFieldForUser(username string, locked bool) error { return nil } + +// SetUserID updates the UID of a user. +func (m *Manager) SetUserID(username string, newUID uint32) error { + // Temporarily disable foreign key constraints to allow updating the UID without violating constraints. + // SQLite does not allow disabling foreign key constraints in a transaction, + // so we do it before starting the transaction. See https://www.sqlite.org/foreignkeys.html#fk_enable + if _, err := m.db.Exec(`PRAGMA foreign_keys = OFF`); err != nil { + return err + } + defer func() { + // Re-enable foreign key constraints after the operation + if _, err := m.db.Exec(`PRAGMA foreign_keys = ON`); err != nil { + log.Errorf(context.TODO(), "Failed to re-enable foreign keys: %v", err) + } + }() + + // Start a transaction + 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 new UID is already in use + existingUser, err := userByID(tx, newUID) + if err != nil && !errors.Is(err, NoDataFoundError{}) { + return fmt.Errorf("failed to check if new UID is already in use: %w", err) + } + if existingUser.Name != "" && existingUser.Name != username { + log.Errorf(context.TODO(), "UID %d already in use by user %q", newUID, existingUser.Name) + return fmt.Errorf("UID %d already in use by a different user", newUID) + } + if existingUser.Name == username { + log.Debugf(context.TODO(), "User %q already has UID %d, no update needed", username, newUID) + return nil + } + + // Get the old UID of the user + oldUser, err := userByName(tx, username) + if errors.Is(err, NoDataFoundError{}) { + return err + } + if err != nil { + return fmt.Errorf("failed to get user by name: %w", err) + } + oldUID := oldUser.UID + + // Update the users table + if _, err := tx.Exec(`UPDATE users SET uid = ? WHERE name = ?`, newUID, username); err != nil { + return err + } + + // Update the users_to_groups table + if _, err := tx.Exec(`UPDATE users_to_groups SET uid = ? WHERE uid = ?`, newUID, oldUID); err != nil { + return err + } + + // Update the users_to_local_groups table + if _, err := tx.Exec(`UPDATE users_to_local_groups SET uid = ? WHERE uid = ?`, newUID, oldUID); err != nil { + return err + } + + return nil +} + +// SetGroupID updates the GID of a group and returns the list of users whose primary group was updated. +func (m *Manager) SetGroupID(groupName string, newGID uint32) ([]UserRow, error) { + // Temporarily disable foreign key constraints to allow updating the GID without violating constraints. + // SQLite does not allow disabling foreign key constraints in a transaction, + // so we do it before starting the transaction. See https://www.sqlite.org/foreignkeys.html#fk_enable + if _, err := m.db.Exec(`PRAGMA foreign_keys = OFF`); err != nil { + return nil, err + } + defer func() { + // Re-enable foreign key constraints after the operation + if _, err := m.db.Exec(`PRAGMA foreign_keys = ON`); err != nil { + log.Errorf(context.TODO(), "Failed to re-enable foreign keys: %v", err) + } + }() + + // Start a transaction + tx, err := m.db.Begin() + if err != nil { + return nil, 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 new GID is already in use + existingGroup, err := groupByID(tx, newGID) + if err != nil && !errors.Is(err, NoDataFoundError{}) { + return nil, fmt.Errorf("failed to check if new GID is already in use: %w", err) + } + if existingGroup.Name != "" && existingGroup.Name != groupName { + log.Errorf(context.TODO(), "GID %d already in use by group %q", newGID, existingGroup.Name) + return nil, fmt.Errorf("GID %d already in use by a different group", newGID) + } + if existingGroup.Name == groupName { + log.Debugf(context.TODO(), "Group %q already has GID %d, no update needed", groupName, newGID) + return nil, nil + } + + // Get the old GID of the group + oldGroup, err := groupByName(tx, groupName) + if errors.Is(err, NoDataFoundError{}) { + return nil, err + } + if err != nil { + return nil, fmt.Errorf("failed to get group by name: %w", err) + } + oldGID := oldGroup.GID + + // Get the list of users whose primary group is the old GID + query := `SELECT name, uid, gid, gecos, dir, shell, broker_id, locked FROM users WHERE gid = ?` + rows, err := tx.Query(query, oldGID) + if err != nil { + return nil, fmt.Errorf("failed to get users with old group as primary group: %w", err) + } + defer closeRows(rows) + + 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, &u.Locked) + if err != nil { + return nil, fmt.Errorf("scan error: %w", err) + } + users = append(users, u) + } + + // Update the groups table + if _, err := tx.Exec(`UPDATE groups SET gid = ? WHERE name = ?`, newGID, groupName); err != nil { + return nil, err + } + + // Update the primary groups of the users table + if _, err := tx.Exec(`UPDATE users SET gid = ? WHERE gid = ?`, newGID, oldGID); err != nil { + return nil, err + } + + // Update the users_to_groups table + if _, err := tx.Exec(`UPDATE users_to_groups SET gid = ? WHERE gid = ?`, newGID, oldGID); err != nil { + // If a foreign key error occurs, enrich it similarly to users handling if needed. + var sqliteErr sqlite3.Error + if errors.As(err, &sqliteErr) && sqliteErr.ExtendedCode == sqlite3.ErrConstraintForeignKey { + // Check existence to provide clearer message + _, groupErr := groupByID(tx, newGID) + if errors.Is(groupErr, NoDataFoundError{}) { + err = fmt.Errorf("%w (%w)", err, groupErr) + } else if groupErr != nil { + err = errors.Join(err, fmt.Errorf("failed to check if group with GID %d exists: %w", newGID, groupErr)) + } + } + return nil, fmt.Errorf("failed to update users_to_groups for GID change: %w", err) + } + + return users, nil +} diff --git a/internal/users/export_test.go b/internal/users/export_test.go index 3d4673af8a..d69fb3514d 100644 --- a/internal/users/export_test.go +++ b/internal/users/export_test.go @@ -22,6 +22,10 @@ func CompareNewUserInfoWithUserInfoFromDB(newUserInfo, dbUserInfo types.UserInfo return compareNewUserInfoWithUserInfoFromDB(newUserInfo, dbUserInfo) } +func GetHomeDirOwner(home string) (uid uint32, gid uint32, err error) { + return getHomeDirOwner(home) +} + const ( SystemdDynamicUIDMin = systemdDynamicUIDMin SystemdDynamicUIDMax = systemdDynamicUIDMax diff --git a/internal/users/locking/locking_bwrap_test.go b/internal/users/locking/locking_bwrap_test.go index 89fa775e45..c31a69f6b1 100644 --- a/internal/users/locking/locking_bwrap_test.go +++ b/internal/users/locking/locking_bwrap_test.go @@ -222,7 +222,6 @@ func TestLockingLockedDatabase(t *testing.T) { } if !testutils.RunningInBubblewrap() { - testutils.SkipIfCannotRunBubbleWrap(t) testInBubbleWrapWithLockerBinary(t) return } @@ -476,7 +475,7 @@ func compileLockerBinary(t *testing.T, tempDir string) { func testInBubbleWrapWithLockerBinary(t *testing.T) { t.Helper() - testutils.SkipIfCannotRunBubbleWrap(t) + testutils.RequireBubblewrap(t) compileLockerBinaryOnce.Do(func() { compileLockerBinary(t, tempDir) diff --git a/internal/users/manager.go b/internal/users/manager.go index 84b763aa2b..dd137c7515 100644 --- a/internal/users/manager.go +++ b/internal/users/manager.go @@ -7,13 +7,18 @@ import ( "fmt" "math" "os" + "os/user" "slices" + "strconv" "sync" "syscall" "github.com/canonical/authd/internal/decorate" + "github.com/canonical/authd/internal/fileutils" "github.com/canonical/authd/internal/users/db" "github.com/canonical/authd/internal/users/localentries" + userslocking "github.com/canonical/authd/internal/users/locking" + "github.com/canonical/authd/internal/users/proc" "github.com/canonical/authd/internal/users/tempentries" "github.com/canonical/authd/internal/users/types" "github.com/canonical/authd/log" @@ -314,7 +319,7 @@ func (m *Manager) UpdateUser(u types.UserInfo) (err error) { return err } - if err = checkHomeDirOwnership(userRow.Dir, userRow.UID, userRow.GID); err != nil { + if err = checkHomeDirOwner(userRow.Dir, userRow.UID, userRow.GID); err != nil { log.Warningf(context.Background(), "Failed to check home directory ownership: %v", err) } @@ -364,6 +369,183 @@ func compareNewUserInfoWithUserInfoFromDB(newUserInfo, dbUserInfo types.UserInfo return dbUserInfo.Equals(newUserInfo) } +// SetUserID updates the UID of the user with the given name to the specified UID. +func (m *Manager) SetUserID(name string, uid uint32) (warnings []string, err error) { + log.Debugf(context.TODO(), "Updating UID for user %q to %d", name, uid) + + if name == "" { + return nil, errors.New("empty username") + } + + if uid > math.MaxInt32 { + return nil, fmt.Errorf("UID %d is too large to convert to int32", uid) + } + + m.userManagementMu.Lock() + defer m.userManagementMu.Unlock() + + // Call lckpwdf to avoid race conditions with other processes which add UIDs + err = userslocking.WriteLock() + if err != nil { + return nil, err + } + defer func() { err = errors.Join(err, userslocking.WriteUnlock()) }() + + // Check if the user exists + oldUser, err := m.db.UserByName(name) + if err != nil { + return nil, err + } + // Check if the user already has the given UID + if oldUser.UID == uid { + warning := fmt.Sprintf("User %q already has UID %d", name, uid) + log.Info(context.Background(), warning) + return []string{warning}, nil + } + + // Check if another user already has the given UID + _, err = user.LookupId(strconv.FormatUint(uint64(uid), 10)) + var userErr user.UnknownUserIdError + if err != nil && !errors.As(err, &userErr) { + // Unexpected error + return nil, err + } + if err == nil { + return nil, fmt.Errorf("UID %d already exists", uid) + } + + // Check if the user has active processes + err = proc.CheckUserBusy(name, oldUser.UID) + if err != nil { + return nil, err + } + + err = m.db.SetUserID(name, uid) + if err != nil { + return nil, err + } + + // Check if the home directory is currently owned by the user. + homeUID, _, err := getHomeDirOwner(oldUser.Dir) + if err != nil && !errors.Is(err, os.ErrNotExist) { + warning := fmt.Sprintf("Could not get owner of home directory %q", oldUser.Dir) + log.Warningf(context.Background(), "%s: %v", warning, err) + return []string{warning}, nil + } + if errors.Is(err, os.ErrNotExist) { + // The home directory does not exist, so we don't need to change the owner. + log.Debugf(context.Background(), "Home directory %q for user %q does not exist, skipping ownership change", oldUser.Dir, name) + return nil, nil + } + + if homeUID != oldUser.UID { + warning := fmt.Sprintf("Not changing ownership of home directory %q, because it is not owned by UID %d (current owner: %d)", oldUser.Dir, oldUser.UID, homeUID) + log.Warning(context.Background(), warning) + return []string{warning}, nil + } + + // Change the ownership of all files in the home directory from the old UID to the new UID. + log.Debugf(context.Background(), "Changing ownership of home directory %q from UID %d to UID %d", oldUser.Dir, oldUser.UID, uid) + err = fileutils.ChownRecursiveFrom(oldUser.Dir, oldUser.UID, 0, int32(uid), -1) + if err != nil { + return nil, err + } + + return nil, nil +} + +// SetGroupID updates the GID of the group with the given name to the specified GID. +func (m *Manager) SetGroupID(name string, gid uint32) (warnings []string, err error) { + log.Debugf(context.TODO(), "Updating GID for group %q to %d", name, gid) + + if name == "" { + return nil, errors.New("empty group name") + } + + if gid > math.MaxInt32 { + return nil, fmt.Errorf("GID %d is too large to convert to int32", gid) + } + + m.userManagementMu.Lock() + defer m.userManagementMu.Unlock() + + // Call lckpwdf to avoid race conditions with other processes which add GIDs + err = userslocking.WriteLock() + if err != nil { + return nil, err + } + defer func() { err = errors.Join(err, userslocking.WriteUnlock()) }() + + // Check if the group already has the given GID + oldGroup, err := m.db.GroupByName(name) + if err != nil { + return nil, err + } + if oldGroup.GID == gid { + warning := fmt.Sprintf("Group %q already has GID %d", name, gid) + log.Info(context.Background(), warning) + return []string{warning}, nil + } + + // Check if another group already has the given GID + _, err = user.LookupGroupId(strconv.FormatUint(uint64(gid), 10)) + var userErr user.UnknownGroupIdError + if err != nil && !errors.As(err, &userErr) { + // Unexpected error + return nil, err + } + if err == nil { + return nil, fmt.Errorf("GID %d already exists", gid) + } + + userRows, err := m.db.SetGroupID(name, gid) + if err != nil { + return nil, err + } + + for _, userRow := range userRows { + warning, updateErr := m.updateUserHomeDirOwnership(userRow, oldGroup.GID, int32(gid)) + if updateErr != nil { + err = errors.Join(err, updateErr) + } + if warning != "" { + warnings = append(warnings, warning) + } + } + + return warnings, err +} + +func (m *Manager) updateUserHomeDirOwnership(userRow db.UserRow, oldGID uint32, newGID int32) (warning string, err error) { + // Check if the home directory is currently owned by the group + _, homeGID, err := getHomeDirOwner(userRow.Dir) + if err != nil && !errors.Is(err, os.ErrNotExist) { + warning := fmt.Sprintf("Could not get owner of home directory %q for user %q", userRow.Dir, userRow.Name) + log.Warningf(context.Background(), "%s: %v", warning, err) + return warning, nil + } + if errors.Is(err, os.ErrNotExist) { + // The home directory does not exist, so we don't need to change the owner. + log.Debugf(context.Background(), "Home directory %q for user %q does not exist, skipping ownership change", userRow.Dir, userRow.Name) + return "", nil + } + + if homeGID != oldGID { + warning := fmt.Sprintf("Not changing ownership of home directory %q, because it is not owned by GID %d (current owner: %d)", userRow.Dir, oldGID, homeGID) + log.Warning(context.Background(), warning) + return warning, nil + } + + // Change the ownership of all files in the home directory from the old GID to the new GID. + log.Debugf(context.Background(), "Changing ownership of home directory %q from GID %d to GID %d", userRow.Dir, oldGID, newGID) + err = fileutils.ChownRecursiveFrom(userRow.Dir, 0, oldGID, -1, newGID) + if err != nil { + return "", err + } + + return "", nil +} + // checkGroupNameConflict checks if a group with the given name already exists. // If it does, it checks if it has the same UGID. func (m *Manager) checkGroupNameConflict(name string, ugid string) error { @@ -409,10 +591,24 @@ func (m *Manager) findGroup(group types.GroupInfo) (oldGroup db.GroupRow, err er return m.db.GroupByName(group.Name) } -// checkHomeDirOwnership checks if the home directory of the user is owned by the user and the user's group. -// If not, it logs a warning. -func checkHomeDirOwnership(home string, uid, gid uint32) error { +func getHomeDirOwner(home string) (uid uint32, gid uint32, err error) { fileInfo, err := os.Stat(home) + if err != nil { + return 0, 0, err + } + + sys, ok := fileInfo.Sys().(*syscall.Stat_t) + if !ok { + return 0, 0, errors.New("failed to get file info") + } + + return sys.Uid, sys.Gid, nil +} + +// checkHomeDirOwner checks if the home directory of the user is owned by the user and the user's group. +// If not, it logs a warning. +func checkHomeDirOwner(home string, uid, gid uint32) error { + oldUID, oldGID, err := getHomeDirOwner(home) if err != nil && !errors.Is(err, os.ErrNotExist) { return err } @@ -421,12 +617,6 @@ func checkHomeDirOwnership(home string, uid, gid uint32) error { return nil } - sys, ok := fileInfo.Sys().(*syscall.Stat_t) - if !ok { - return errors.New("failed to get file info") - } - oldUID, oldGID := sys.Uid, sys.Gid - // Check if the home directory is owned by the user. if oldUID != uid && oldGID != gid { log.Warningf(context.Background(), "Home directory %q is not owned by UID %d and GID %d. To fix this, run `sudo chown -R %d:%d %q`.", home, uid, gid, uid, gid, home) diff --git a/internal/users/manager_bwrap_test.go b/internal/users/manager_bwrap_test.go new file mode 100644 index 0000000000..fa3475cb64 --- /dev/null +++ b/internal/users/manager_bwrap_test.go @@ -0,0 +1,419 @@ +package users_test + +import ( + "context" + "fmt" + "math" + "os" + "os/exec" + "path/filepath" + "regexp" + "strconv" + "testing" + + "github.com/canonical/authd/internal/fileutils" + "github.com/canonical/authd/internal/testutils" + "github.com/canonical/authd/internal/testutils/golden" + "github.com/canonical/authd/internal/users" + "github.com/canonical/authd/internal/users/db" + "github.com/canonical/authd/log" + "github.com/stretchr/testify/require" +) + +func TestSetUserID(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + nonExistentUser bool + emptyUsername bool + uidAlreadySet bool + uidAlreadyInUseByAuthdUser bool + uidAlreadyInUseAsGIDofAuthdUser bool + uidAlreadyInUseBySystemUser bool + uidAlreadyInUseAsGIDofSystemUser bool + uidTooLarge bool + homeDirDoesNotExist bool + homeDirOwnedByOtherUser bool + homeDirCannotBeAccessed bool + homeDirOwnerCannotBeChanged bool + + wantErr bool + wantErrType error + wantWarnings int + }{ + "Successfully_set_UID": {}, + "Successfully_set_UID_if_ID_is_already_in_use_as_GID_of_system_user": {uidAlreadyInUseAsGIDofSystemUser: true}, + "Successfully_set_UID_if_ID_is_already_in_use_as_GID_of_authd_user": {uidAlreadyInUseAsGIDofAuthdUser: true}, + "Successfully_set_UID_when_home_directory_does_not_exist": {homeDirDoesNotExist: true}, + + "Warning_if_user_already_has_given_UID": {uidAlreadySet: true, wantWarnings: 1}, + "Warning_if_home_directory_is_owned_by_other_user": {homeDirOwnedByOtherUser: true, wantWarnings: 1}, + "Warning_if_home_directory_cannot_be_accessed": {homeDirCannotBeAccessed: true, wantWarnings: 1}, + + "Error_if_username_is_empty": {emptyUsername: true, wantErr: true}, + "Error_if_user_does_not_exist": {nonExistentUser: true, wantErrType: db.NoDataFoundError{}}, + "Error_if_UID_is_already_in_use_by_authd": {uidAlreadyInUseByAuthdUser: true, wantErr: true}, + "Error_if_UID_is_already_in_use_by_system": {uidAlreadyInUseBySystemUser: true, wantErr: true}, + "Error_if_UID_is_too_large": {uidTooLarge: true, wantErr: true}, + "Error_if_home_directory_owner_cannot_be_changed": {homeDirOwnerCannotBeChanged: true, wantErr: true}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if !testutils.RunningInBubblewrap() { + testutils.RunTestInBubbleWrap(t) + return + } + + dbDir := t.TempDir() + err := db.Z_ForTests_CreateDBFromYAML(filepath.Join("testdata", "db", "multiple_users_and_groups.db.yaml"), dbDir) + require.NoError(t, err, "Setup: could not create database from testdata") + + m := newManagerForTests(t, dbDir) + + username := "user1" + if tc.nonExistentUser { + username = "nonexistent" + } else if tc.emptyUsername { + username = "" + } else if !tc.homeDirDoesNotExist { + uid := 1111 + gid := 11111 + if tc.homeDirOwnedByOtherUser { + uid = 2222 + } + home := createTemporaryHome(t, uid, gid, tc.homeDirCannotBeAccessed, tc.homeDirOwnerCannotBeChanged) + setHome(t, m, username, home) + } + + newUID := 54321 + if tc.uidTooLarge { + newUID = math.MaxInt32 + 1 + } + + if tc.uidAlreadySet { + setUID(t, m, username, newUID) + } + if tc.uidAlreadyInUseByAuthdUser { + setUID(t, m, "user2", newUID) + } + if tc.uidAlreadyInUseAsGIDofAuthdUser { + newUID = 22222 + } + if tc.uidAlreadyInUseBySystemUser { + addUserToSystem(t, newUID) + } + if tc.uidAlreadyInUseAsGIDofSystemUser { + addGroupToSystem(t, newUID) + } + + //nolint:gosec // G115 we set the UID above to values that are valid uint32 + warnings, err := m.SetUserID(username, uint32(newUID)) + log.Infof(context.Background(), "SetUserID error: %v", err) + log.Infof(context.Background(), "SetUserID warnings: %v", warnings) + + if tc.wantErrType != nil { + require.ErrorIs(t, err, tc.wantErrType, "SetUserID should return expected error") + return + } + if tc.wantErr { + require.Error(t, err, "SetUserID should return an error but didn't") + return + } + require.NoError(t, err, "SetUserID should not return an error") + require.Len(t, warnings, tc.wantWarnings, "Unexpected number of warnings") + + yamlData, err := db.Z_ForTests_DumpNormalizedYAML(m.DB()) + require.NoError(t, err) + golden.CheckOrUpdate(t, yamlData, golden.WithPath("db")) + + if len(warnings) == 0 { + return + } + + // To make the tests deterministic, we replace the temporary home directory path with a placeholder + for i, w := range warnings { + if regexp.MustCompile(`Could not get owner of home directory "([^"]+)"`).MatchString(w) { + warnings[i] = `Could not get owner of home directory "{{HOME}}"` + } + if regexp.MustCompile(`Not changing ownership of home directory "([^"]+)", because it is not owned by UID \d+ \(current owner: \d+\)`).MatchString(w) { + warnings[i] = `Not changing ownership of home directory "{{HOME}}", because it is not owned by UID {{UID}} (current owner: {{CURR_UID}})` + } + } + + golden.CheckOrUpdateYAML(t, warnings, golden.WithPath("warnings")) + }) + } +} + +func TestSetGroupID(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + nonExistentGroup bool + emptyGroupname bool + gidAlreadySet bool + gidAlreadyInUseByAuthdGroup bool + gidAlreadyInUseAsUIDofAuthdUser bool + gidAlreadyInUseBySystemGroup bool + gidAlreadyInUseAsUIDofSystemUser bool + gidIsPrimaryGroupOfMultipleUsers bool + gidIsNotPrimaryGroupOfAnyUser bool + gidTooLarge bool + homeDirDoesNotExist bool + homeDirOwnedByOtherGroup bool + homeDirCannotBeAccessed bool + homeDirOwnerCannotBeChanged bool + + wantErr bool + wantErrType error + wantWarnings int + }{ + "Successfully_set_GID": {}, + "Successfully_set_GID_if_ID_is_already_in_use_as_UID_of_system_user": {gidAlreadyInUseAsUIDofSystemUser: true}, + "Successfully_set_GID_if_ID_is_already_in_use_as_UID_of_authd_user": {gidAlreadyInUseAsUIDofAuthdUser: true}, + "Successfully_set_GID_when_home_directory_does_not_exist": {homeDirDoesNotExist: true}, + "Successfully_set_GID_when_group_is_not_primary_group_of_any_user": {gidIsNotPrimaryGroupOfAnyUser: true}, + "Primary_groups_of_multiple_users_are_updated": {gidIsPrimaryGroupOfMultipleUsers: true}, + + "Warning_if_group_already_has_given_GID": {gidAlreadySet: true, wantWarnings: 1}, + "Warning_if_home_directory_is_owned_by_other_group": {homeDirOwnedByOtherGroup: true, wantWarnings: 1}, + "Warning_if_home_directory_cannot_be_accessed": {homeDirCannotBeAccessed: true, wantWarnings: 1}, + + "Error_if_groupname_is_empty": {emptyGroupname: true, wantErr: true}, + "Error_if_group_does_not_exist": {nonExistentGroup: true, wantErrType: db.NoDataFoundError{}}, + "Error_if_GID_is_already_in_use_by_authd": {gidAlreadyInUseByAuthdGroup: true, wantErr: true}, + "Error_if_GID_is_already_in_use_by_system": {gidAlreadyInUseBySystemGroup: true, wantErr: true}, + "Error_if_GID_is_too_large": {gidTooLarge: true, wantErr: true}, + "Error_if_home_directory_owner_cannot_be_changed": {homeDirOwnerCannotBeChanged: true, wantErr: true}, + } + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + if !testutils.RunningInBubblewrap() { + testutils.RunTestInBubbleWrap(t) + return + } + + dbDir := t.TempDir() + err := db.Z_ForTests_CreateDBFromYAML(filepath.Join("testdata", "db", "multiple_users_and_groups.db.yaml"), dbDir) + require.NoError(t, err, "Setup: could not create database from testdata") + + m := newManagerForTests(t, dbDir) + + groupname := "group1" + if tc.nonExistentGroup { + groupname = "nonexistent" + } else if tc.emptyGroupname { + groupname = "" + } else if !tc.homeDirDoesNotExist { + uid := 1111 + gid := 11111 + if tc.homeDirOwnedByOtherGroup { + gid = 2222 + } + home := createTemporaryHome(t, uid, gid, tc.homeDirCannotBeAccessed, tc.homeDirOwnerCannotBeChanged) + setHome(t, m, "user1", home) + } + + newGID := 54321 + if tc.gidTooLarge { + newGID = math.MaxInt32 + 1 + } + + if tc.gidAlreadySet { + setGID(t, m, groupname, newGID) + } + if tc.gidAlreadyInUseByAuthdGroup { + setGID(t, m, "group3", newGID) + } + if tc.gidAlreadyInUseAsUIDofAuthdUser { + setUID(t, m, "user2", newGID) + } + if tc.gidAlreadyInUseBySystemGroup { + addGroupToSystem(t, newGID) + } + if tc.gidAlreadyInUseAsUIDofSystemUser { + addUserToSystem(t, newGID) + } + if tc.gidIsNotPrimaryGroupOfAnyUser { + // Change the primary group of "user1" to another group + setPrimaryGroup(t, m, "user1", 22222) + } + if tc.gidIsPrimaryGroupOfMultipleUsers { + // Change the primary group of "user2" to the group we want to change + setPrimaryGroup(t, m, "user2", 11111) + } + + //nolint:gosec // G115 we set the GID above to values that are valid uint32 + warnings, err := m.SetGroupID(groupname, uint32(newGID)) + log.Infof(context.Background(), "SetGroupID error: %v", err) + log.Infof(context.Background(), "SetGroupID warnings: %v", warnings) + + if tc.wantErrType != nil { + require.ErrorIs(t, err, tc.wantErrType, "SetGroupID should return expected error") + return + } + if tc.wantErr { + require.Error(t, err, "SetGroupID should return an error but didn't") + return + } + require.NoError(t, err, "SetGroupID should not return an error") + require.Len(t, warnings, tc.wantWarnings, "Unexpected number of warnings") + + yamlData, err := db.Z_ForTests_DumpNormalizedYAML(m.DB()) + require.NoError(t, err) + golden.CheckOrUpdate(t, yamlData, golden.WithPath("db")) + + if len(warnings) == 0 { + return + } + + // To make the tests deterministic, we replace the temporary home directory path with a placeholder + for i, w := range warnings { + if regexp.MustCompile(`Could not get owner of home directory "([^"]+)"`).MatchString(w) { + warnings[i] = `Could not get owner of home directory "{{HOME}}"` + } + if regexp.MustCompile(`Not changing ownership of home directory "([^"]+)", because it is not owned by GID \d+ \(current owner: \d+\)`).MatchString(w) { + warnings[i] = `Not changing ownership of home directory "{{HOME}}", because it is not owned by GID {{GID}} (current owner: {{CURR_GID}})` + } + } + + golden.CheckOrUpdateYAML(t, warnings, golden.WithPath("warnings")) + }) + } +} + +// createTemporaryHome creates a temporary home directory for the given user. +func createTemporaryHome(t *testing.T, uid, gid int, inaccessible, cannotBeChanged bool) string { + t.Helper() + + // We use a deterministic path (/tmp/home) here because the home directory + // is stored in the database which we dump to a golden file, so we would + // have to replace the path in the golden file to make it deterministic. + // It's simpler to just use a deterministic path here. + parentDir := filepath.Join(os.TempDir(), "home") + home := filepath.Join(parentDir, fmt.Sprintf("user-%d", uid)) + + if inaccessible { + // Create the parent directory as a file, so that the home directory cannot be accessed. + err := fileutils.Touch(parentDir) + require.NoError(t, err, "Setup: could not create temporary file") + return home + } + + err := os.MkdirAll(parentDir, 0700) + require.NoError(t, err, "Setup: could not create parent directory for home") + + if cannotBeChanged { + //nolint:gosec // G204 we want to use exec.Command with variables here + cmd := exec.Command("mount", "-t", "tmpfs", "tmpfs", parentDir) + cmd.Stdout = t.Output() + cmd.Stderr = t.Output() + err := cmd.Run() + require.NoError(t, err, "Setup: could not mount tmpfs") + } + + // Create the home directory and chown it to the user + err = os.MkdirAll(home, 0700) + require.NoError(t, err, "Setup: could not create home directory") + + err = os.Chown(home, uid, gid) + require.NoError(t, err, "Setup: could not chown home directory") + + if cannotBeChanged { + //nolint:gosec // G204 we want to use exec.Command with variables here + cmd := exec.Command("mount", "-o", "remount,ro", parentDir, parentDir) + cmd.Stdout = t.Output() + cmd.Stderr = t.Output() + err = cmd.Run() + require.NoError(t, err, "Setup: could not remount tmpfs read-only") + } + + return home +} + +// setHome updates the home directory of the given user. +func setHome(t *testing.T, m *users.Manager, username string, home string) { + t.Helper() + + u, err := m.DB().UserByName(username) + require.NoError(t, err, "Setup: could not get user by ID") + + // UpdateUserEntry doesn't update the home directory if the user already exists + // and has a non-empty home directory set. We need to delete the user first. + err = m.DB().DeleteUser(u.UID) + require.NoError(t, err, "Setup: could not delete user") + + // Set the new home directory + u.Dir = home + + // Re-add the user with the new home directory + err = m.DB().UpdateUserEntry(u, nil, nil) + require.NoError(t, err, "Setup: could not update user") +} + +func setUID(t *testing.T, m *users.Manager, username string, uid int) { + t.Helper() + + if uid < 0 || uid > math.MaxUint32 { + require.Fail(t, "Setup: invalid UID %d", uid) + } + + err := m.DB().SetUserID(username, uint32(uid)) + require.NoError(t, err, "Setup: could not set user ID") +} + +func setGID(t *testing.T, m *users.Manager, groupname string, gid int) { + t.Helper() + + if gid < 0 || gid > math.MaxUint32 { + require.Fail(t, "Setup: invalid GID %d", gid) + } + + _, err := m.DB().SetGroupID(groupname, uint32(gid)) + require.NoError(t, err, "Setup: could not set group ID") +} + +// setPrimaryGroup updates the primary group of the given user. +func setPrimaryGroup(t *testing.T, m *users.Manager, username string, gid uint32) { + t.Helper() + + u, err := m.DB().UserByName(username) + require.NoError(t, err, "Setup: could not get user by ID") + + u.GID = gid + + err = m.DB().UpdateUserEntry(u, nil, nil) + require.NoError(t, err, "Setup: could not update user") +} + +func addUserToSystem(t *testing.T, uid int) { + t.Helper() + + //nolint:gosec // G204 we want to use exec.Command with variables here + cmd := exec.Command( + "useradd", + "--uid", strconv.Itoa(uid), + "--gid", "0", + "--no-create-home", + fmt.Sprintf("test-%d", uid), + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Setup: useradd failed: %s", output) +} + +func addGroupToSystem(t *testing.T, gid int) { + t.Helper() + + //nolint:gosec // G204 we want to use exec.Command with variables here + cmd := exec.Command( + "groupadd", + "--gid", strconv.Itoa(gid), + fmt.Sprintf("test-%d", gid), + ) + output, err := cmd.CombinedOutput() + require.NoError(t, err, "Setup: groupadd failed: %s", output) +} diff --git a/internal/users/manager_test.go b/internal/users/manager_test.go index 8fdda831b9..8e80cb83bb 100644 --- a/internal/users/manager_test.go +++ b/internal/users/manager_test.go @@ -10,6 +10,7 @@ import ( "strings" "sync" "sync/atomic" + "syscall" "testing" "time" @@ -1305,6 +1306,98 @@ func requireErrorAssertions(t *testing.T, gotErr, wantErrType error, wantErr boo require.NoError(t, gotErr, "Error should not be returned") } +func TestGetHomeDirOwner(t *testing.T) { + t.Parallel() + + tests := map[string]struct { + setupDir func(t *testing.T) string + wantErr bool + wantErrType error + }{ + "Successfully_get_owner_of_existing_directory": { + setupDir: func(t *testing.T) string { + t.Helper() + dir := t.TempDir() + return dir + }, + }, + "Successfully_get_owner_of_file": { + setupDir: func(t *testing.T) string { + t.Helper() + dir := t.TempDir() + file := filepath.Join(dir, "testfile") + err := os.WriteFile(file, []byte("test"), 0644) + require.NoError(t, err, "Setup: failed to create test file") + return file + }, + }, + "Error_when_directory_does_not_exist": { + setupDir: func(t *testing.T) string { + t.Helper() + dir := t.TempDir() + return filepath.Join(dir, "nonexistent") + }, + wantErr: true, + wantErrType: os.ErrNotExist, + }, + "Error_when_directory_is_inaccessible": { + setupDir: func(t *testing.T) string { + t.Helper() + // Skip this test if running as root, as root can access everything + if os.Getuid() == 0 { + t.Skip("Skipping test when running as root") + } + dir := t.TempDir() + // Create a subdirectory with no read permissions + subdir := filepath.Join(dir, "inaccessible") + err := os.Mkdir(subdir, 0000) + require.NoError(t, err, "Setup: failed to create inaccessible directory") + t.Cleanup(func() { + // Restore permissions for cleanup + os.Chmod(subdir, 0755) + }) + return filepath.Join(subdir, "nested") + }, + wantErr: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + path := tc.setupDir(t) + + uid, gid, err := users.GetHomeDirOwner(path) + + if tc.wantErr { + require.Error(t, err, "GetHomeDirOwner should return an error") + if tc.wantErrType != nil { + require.ErrorIs(t, err, tc.wantErrType, "Error should be of expected type") + } + return + } + require.NoError(t, err, "GetHomeDirOwner should not return an error") + + // Verify that UID and GID are valid by checking they are reasonable values. + // We can't always assume they match the current process UID/GID in all testing + // environments (e.g., containers, CI systems), so we just verify the function + // successfully retrieves ownership information. + + // Get actual file info to verify correctness + fileInfo, statErr := os.Stat(path) + require.NoError(t, statErr, "Setup: failed to stat path") + + sys, ok := fileInfo.Sys().(*syscall.Stat_t) + require.True(t, ok, "Setup: failed to get syscall.Stat_t") + + // Verify the returned values match what we get from os.Stat + require.Equal(t, sys.Uid, uid, "UID should match file's actual UID") + require.Equal(t, sys.Gid, gid, "GID should match file's actual GID") + }) + } +} + func newManagerForTests(t *testing.T, dbDir string, opts ...users.Option) *users.Manager { t.Helper() @@ -1317,6 +1410,11 @@ func newManagerForTests(t *testing.T, dbDir string, opts ...users.Option) *users func TestMain(m *testing.M) { log.SetLevel(log.DebugLevel) + if testutils.RunningInBubblewrap() { + m.Run() + return + } + userslocking.Z_ForTests_OverrideLocking() defer userslocking.Z_ForTests_RestoreLocking() diff --git a/internal/users/proc/proc.go b/internal/users/proc/proc.go new file mode 100644 index 0000000000..94f96c4ab4 --- /dev/null +++ b/internal/users/proc/proc.go @@ -0,0 +1,209 @@ +// Package proc contains utilities for checking processes via /proc. +package proc + +import ( + "bufio" + "context" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + + "github.com/canonical/authd/log" +) + +// ErrUserBusy is returned by CheckUserBusy if the user is currently used by a process. +var ErrUserBusy = errors.New("user is currently used by process") + +// CheckUserBusy checks if a user is currently running any processes. +// +// It is a re-implementation of this user_busy_processes() function: +// https://github.com/shadow-maint/shadow/blob/e78742e553c12222b40b13224d3b0fafaceae791/lib/user_busy.c#L164-L272 +// +// It returns ErrUserBusy if the user has active processes, nil otherwise. +func CheckUserBusy(name string, uid uint32) error { + log.Debugf(context.Background(), "Checking if user %s (uid %d) is busy", name, uid) + var rootStat syscall.Stat_t + if err := syscall.Stat("/", &rootStat); err != nil { + return fmt.Errorf("stat (\"/\"): %w", err) + } + + procDir, err := os.Open("/proc") + if err != nil { + return fmt.Errorf("opendir /proc: %w", err) + } + defer func() { + _ = procDir.Close() + }() + + entries, err := procDir.Readdir(-1) + if err != nil { + return err + } + + for _, entry := range entries { + entryName := entry.Name() + + /* + * Ingo Molnar's patch introducing NPTL for 2.4 hides + * threads in the /proc directory by prepending a period. + * This patch is applied by default in some RedHat + * kernels. + */ + if entryName == "." || entryName == ".." { + continue + } + entryName = strings.TrimPrefix(entryName, ".") + + /* Check if this is a valid PID */ + pid, err := strconv.Atoi(entryName) + if err != nil { + continue + } + + /* Check if the process is in our chroot */ + rootPath := fmt.Sprintf("/proc/%d/root", pid) + var procRootStat syscall.Stat_t + if err := syscall.Stat(rootPath, &procRootStat); err != nil { + continue + } + if rootStat.Dev != procRootStat.Dev || rootStat.Ino != procRootStat.Ino { + continue + } + + if checkStatus(name, entryName, uid) { + return fmt.Errorf("%w %d", ErrUserBusy, pid) + } + + taskPath := fmt.Sprintf("/proc/%d/task", pid) + taskDir, err := os.Open(taskPath) + if err != nil { + log.Debugf(context.Background(), "Skipping invalid task path %q: %v", taskPath, err) + continue + } + + taskEntries, err := taskDir.Readdir(-1) + _ = taskDir.Close() + if err != nil { + log.Debugf(context.Background(), "Skipping invalid task directory %q: %v", taskPath, err) + continue + } + + for _, taskEntry := range taskEntries { + tid, err := strconv.Atoi(taskEntry.Name()) + if err != nil || tid == pid { + continue + } + + taskStatusPath := filepath.Join(strconv.Itoa(pid), "task", taskEntry.Name()) + if checkStatus(name, taskStatusPath, uid) { + return fmt.Errorf("%w %d", ErrUserBusy, pid) + } + } + } + + return nil +} + +func differentNamespace(sname string) bool { + path := filepath.Join("/proc", sname, "ns", "user") + + dest1, err := os.Readlink(path) + if err != nil { + return false + } + + dest2, err := os.Readlink("/proc/self/ns/user") + if err != nil { + return false + } + + return dest1 != dest2 +} + +func checkStatus(username, sname string, uid uint32) bool { + statusPath := filepath.Join("/proc", sname, "status") + f, err := os.Open(statusPath) + if err != nil { + return false + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + if rest, ok := strings.CutPrefix(line, "Uid:\t"); ok { + fields := strings.Fields(rest) + if len(fields) < 3 { + return false + } + var uids [3]uint32 + for i := 0; i < 3; i++ { + uid64, err := strconv.ParseUint(fields[i], 10, 32) + if err != nil { + return false + } + uids[i] = uint32(uid64) + } + realUID, effectiveUID, savedUID := uids[0], uids[1], uids[2] + + if realUID == uid || effectiveUID == uid || savedUID == uid { + return true + } + + // Check sub-UIDs only if in different namespace + if differentNamespace(sname) && + (checkSubUID(username, realUID) || + checkSubUID(username, effectiveUID) || + checkSubUID(username, savedUID)) { + return true + } + + return false + } + } + if err := scanner.Err(); err != nil { + return false + } + + return false +} + +func checkSubUID(username string, nsUID uint32) bool { + f, err := os.Open("/etc/subuid") + if err != nil { + return false + } + defer func() { _ = f.Close() }() + + scanner := bufio.NewScanner(f) + for scanner.Scan() { + line := scanner.Text() + fields := strings.Split(line, ":") + if len(fields) != 3 { + continue + } + if fields[0] != username { + continue + } + + start, err1 := strconv.ParseUint(fields[1], 10, 32) + count, err2 := strconv.ParseUint(fields[2], 10, 32) + if err1 != nil || err2 != nil { + log.Debugf(context.Background(), "Invalid subuid entry %q: %v", line, err) + continue + } + + if nsUID >= uint32(start) && nsUID < uint32(start)+uint32(count) { + return true + } + } + if err := scanner.Err(); err != nil { + return false + } + + return false +} diff --git a/internal/users/proc/proc_test.go b/internal/users/proc/proc_test.go new file mode 100644 index 0000000000..35a11da5a3 --- /dev/null +++ b/internal/users/proc/proc_test.go @@ -0,0 +1,49 @@ +package proc_test + +import ( + "os" + "os/user" + "testing" + + "github.com/canonical/authd/internal/users/proc" + "github.com/stretchr/testify/require" +) + +func TestCheckUserBusy(t *testing.T) { + t.Parallel() + + currentUser, err := user.Current() + require.NoError(t, err, "failed to get current user") + + tests := map[string]struct { + user string + uid uint32 + wantError bool + }{ + "The_nobody_user_has_no_processes": { + user: "nobody", + uid: 65534, + wantError: false, + }, + "The_current_user_has_processes": { + user: currentUser.Name, + //nolint:gosec // G115 UIDs are never negative + uid: uint32(os.Getuid()), + wantError: true, + }, + } + + for name, tc := range tests { + t.Run(name, func(t *testing.T) { + t.Parallel() + + err := proc.CheckUserBusy(tc.user, tc.uid) + t.Logf("CheckUserBusy returned: %v", err) + if tc.wantError { + require.Error(t, err, "CheckUserBusy should return an error") + return + } + require.NoError(t, err, "CheckUserBusy should not return an error") + }) + } +} diff --git a/internal/users/testdata/golden/TestSetGroupID/Primary_groups_of_multiple_users_are_updated/db b/internal/users/testdata/golden/TestSetGroupID/Primary_groups_of_multiple_users_are_updated/db new file mode 100644 index 0000000000..1351fb02dc --- /dev/null +++ b/internal/users/testdata/golden/TestSetGroupID/Primary_groups_of_multiple_users_are_updated/db @@ -0,0 +1,56 @@ +users: + - name: user1 + uid: 1111 + gid: 54321 + gecos: |- + User1 gecos + On multiple lines + dir: /tmp/home/user-1111 + shell: /bin/bash + broker_id: broker-id + - name: user2 + uid: 2222 + gid: 54321 + 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: group2withoutugid + gid: 22222 + ugid: "" + - name: group3 + gid: 33333 + ugid: "34567812" + - name: group4 + gid: 44444 + ugid: "45678123" + - name: group1 + gid: 54321 + ugid: "12345678" + - name: commongroup + gid: 99999 + ugid: "87654321" +users_to_groups: + - 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/TestSetGroupID/Successfully_set_GID/db b/internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID/db new file mode 100644 index 0000000000..62c8d1b5ca --- /dev/null +++ b/internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID/db @@ -0,0 +1,60 @@ +users: + - name: user1 + uid: 1111 + gid: 54321 + gecos: |- + User1 gecos + On multiple lines + dir: /tmp/home/user-1111 + shell: /bin/bash + broker_id: broker-id + - 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: group2withoutugid + gid: 22222 + ugid: "" + - name: group3 + gid: 33333 + ugid: "34567812" + - name: group4 + gid: 44444 + ugid: "45678123" + - name: group1 + gid: 54321 + ugid: "12345678" + - name: commongroup + gid: 99999 + ugid: "87654321" +users_to_groups: + - 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/TestSetGroupID/Successfully_set_GID_if_ID_is_already_in_use_as_UID_of_authd_user/db b/internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID_if_ID_is_already_in_use_as_UID_of_authd_user/db new file mode 100644 index 0000000000..2793efe41d --- /dev/null +++ b/internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID_if_ID_is_already_in_use_as_UID_of_authd_user/db @@ -0,0 +1,60 @@ +users: + - name: user1 + uid: 1111 + gid: 54321 + gecos: |- + User1 gecos + On multiple lines + dir: /tmp/home/user-1111 + shell: /bin/bash + 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 + - name: user2 + uid: 54321 + gid: 22222 + gecos: User2 + dir: /home/user2 + shell: /bin/dash + broker_id: broker-id +groups: + - name: group2withoutugid + gid: 22222 + ugid: "" + - name: group3 + gid: 33333 + ugid: "34567812" + - name: group4 + gid: 44444 + ugid: "45678123" + - name: group1 + gid: 54321 + ugid: "12345678" + - name: commongroup + gid: 99999 + ugid: "87654321" +users_to_groups: + - uid: 3333 + gid: 33333 + - uid: 3333 + gid: 99999 + - uid: 4444 + gid: 44444 + - uid: 4444 + gid: 99999 + - uid: 54321 + gid: 22222 + - uid: 54321 + gid: 99999 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID_if_ID_is_already_in_use_as_UID_of_system_user/db b/internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID_if_ID_is_already_in_use_as_UID_of_system_user/db new file mode 100644 index 0000000000..62c8d1b5ca --- /dev/null +++ b/internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID_if_ID_is_already_in_use_as_UID_of_system_user/db @@ -0,0 +1,60 @@ +users: + - name: user1 + uid: 1111 + gid: 54321 + gecos: |- + User1 gecos + On multiple lines + dir: /tmp/home/user-1111 + shell: /bin/bash + broker_id: broker-id + - 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: group2withoutugid + gid: 22222 + ugid: "" + - name: group3 + gid: 33333 + ugid: "34567812" + - name: group4 + gid: 44444 + ugid: "45678123" + - name: group1 + gid: 54321 + ugid: "12345678" + - name: commongroup + gid: 99999 + ugid: "87654321" +users_to_groups: + - 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/TestSetGroupID/Successfully_set_GID_when_group_is_not_primary_group_of_any_user/db b/internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID_when_group_is_not_primary_group_of_any_user/db new file mode 100644 index 0000000000..da9424d893 --- /dev/null +++ b/internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID_when_group_is_not_primary_group_of_any_user/db @@ -0,0 +1,60 @@ +users: + - name: user1 + uid: 1111 + gid: 22222 + gecos: |- + User1 gecos + On multiple lines + dir: /tmp/home/user-1111 + shell: /bin/bash + broker_id: broker-id + - 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: group2withoutugid + gid: 22222 + ugid: "" + - name: group3 + gid: 33333 + ugid: "34567812" + - name: group4 + gid: 44444 + ugid: "45678123" + - name: group1 + gid: 54321 + ugid: "12345678" + - name: commongroup + gid: 99999 + ugid: "87654321" +users_to_groups: + - 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/TestSetGroupID/Successfully_set_GID_when_home_directory_does_not_exist/db b/internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID_when_home_directory_does_not_exist/db new file mode 100644 index 0000000000..3b8c139bce --- /dev/null +++ b/internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID_when_home_directory_does_not_exist/db @@ -0,0 +1,64 @@ +users: + - name: user1 + uid: 1111 + gid: 54321 + gecos: |- + User1 gecos + On multiple lines + dir: /home/user1 + shell: /bin/bash + broker_id: broker-id + - 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: group2withoutugid + gid: 22222 + ugid: "" + - name: group3 + gid: 33333 + ugid: "34567812" + - name: group4 + gid: 44444 + ugid: "45678123" + - name: group1 + gid: 54321 + ugid: "12345678" + - name: commongroup + gid: 99999 + ugid: "87654321" +users_to_groups: + - uid: 1111 + gid: 54321 + - 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/TestSetGroupID/Warning_if_group_already_has_given_GID/db b/internal/users/testdata/golden/TestSetGroupID/Warning_if_group_already_has_given_GID/db new file mode 100644 index 0000000000..62c8d1b5ca --- /dev/null +++ b/internal/users/testdata/golden/TestSetGroupID/Warning_if_group_already_has_given_GID/db @@ -0,0 +1,60 @@ +users: + - name: user1 + uid: 1111 + gid: 54321 + gecos: |- + User1 gecos + On multiple lines + dir: /tmp/home/user-1111 + shell: /bin/bash + broker_id: broker-id + - 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: group2withoutugid + gid: 22222 + ugid: "" + - name: group3 + gid: 33333 + ugid: "34567812" + - name: group4 + gid: 44444 + ugid: "45678123" + - name: group1 + gid: 54321 + ugid: "12345678" + - name: commongroup + gid: 99999 + ugid: "87654321" +users_to_groups: + - 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/TestSetGroupID/Warning_if_group_already_has_given_GID/warnings b/internal/users/testdata/golden/TestSetGroupID/Warning_if_group_already_has_given_GID/warnings new file mode 100644 index 0000000000..8732486a4f --- /dev/null +++ b/internal/users/testdata/golden/TestSetGroupID/Warning_if_group_already_has_given_GID/warnings @@ -0,0 +1 @@ +- Group "group1" already has GID 54321 diff --git a/internal/users/testdata/golden/TestSetGroupID/Warning_if_home_directory_cannot_be_accessed/db b/internal/users/testdata/golden/TestSetGroupID/Warning_if_home_directory_cannot_be_accessed/db new file mode 100644 index 0000000000..62c8d1b5ca --- /dev/null +++ b/internal/users/testdata/golden/TestSetGroupID/Warning_if_home_directory_cannot_be_accessed/db @@ -0,0 +1,60 @@ +users: + - name: user1 + uid: 1111 + gid: 54321 + gecos: |- + User1 gecos + On multiple lines + dir: /tmp/home/user-1111 + shell: /bin/bash + broker_id: broker-id + - 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: group2withoutugid + gid: 22222 + ugid: "" + - name: group3 + gid: 33333 + ugid: "34567812" + - name: group4 + gid: 44444 + ugid: "45678123" + - name: group1 + gid: 54321 + ugid: "12345678" + - name: commongroup + gid: 99999 + ugid: "87654321" +users_to_groups: + - 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/TestSetGroupID/Warning_if_home_directory_cannot_be_accessed/warnings b/internal/users/testdata/golden/TestSetGroupID/Warning_if_home_directory_cannot_be_accessed/warnings new file mode 100644 index 0000000000..27da56a848 --- /dev/null +++ b/internal/users/testdata/golden/TestSetGroupID/Warning_if_home_directory_cannot_be_accessed/warnings @@ -0,0 +1 @@ +- Could not get owner of home directory "{{HOME}}" diff --git a/internal/users/testdata/golden/TestSetGroupID/Warning_if_home_directory_is_owned_by_other_group/db b/internal/users/testdata/golden/TestSetGroupID/Warning_if_home_directory_is_owned_by_other_group/db new file mode 100644 index 0000000000..62c8d1b5ca --- /dev/null +++ b/internal/users/testdata/golden/TestSetGroupID/Warning_if_home_directory_is_owned_by_other_group/db @@ -0,0 +1,60 @@ +users: + - name: user1 + uid: 1111 + gid: 54321 + gecos: |- + User1 gecos + On multiple lines + dir: /tmp/home/user-1111 + shell: /bin/bash + broker_id: broker-id + - 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: group2withoutugid + gid: 22222 + ugid: "" + - name: group3 + gid: 33333 + ugid: "34567812" + - name: group4 + gid: 44444 + ugid: "45678123" + - name: group1 + gid: 54321 + ugid: "12345678" + - name: commongroup + gid: 99999 + ugid: "87654321" +users_to_groups: + - 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/TestSetGroupID/Warning_if_home_directory_is_owned_by_other_group/warnings b/internal/users/testdata/golden/TestSetGroupID/Warning_if_home_directory_is_owned_by_other_group/warnings new file mode 100644 index 0000000000..d2783dd23d --- /dev/null +++ b/internal/users/testdata/golden/TestSetGroupID/Warning_if_home_directory_is_owned_by_other_group/warnings @@ -0,0 +1 @@ +- 'Not changing ownership of home directory "{{HOME}}", because it is not owned by GID {{GID}} (current owner: {{CURR_GID}})' diff --git a/internal/users/testdata/golden/TestSetUserID/Successfully_set_UID/db b/internal/users/testdata/golden/TestSetUserID/Successfully_set_UID/db new file mode 100644 index 0000000000..6ebb794493 --- /dev/null +++ b/internal/users/testdata/golden/TestSetUserID/Successfully_set_UID/db @@ -0,0 +1,60 @@ +users: + - 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 + - name: user1 + uid: 54321 + gid: 11111 + gecos: |- + User1 gecos + On multiple lines + dir: /tmp/home/user-1111 + shell: /bin/bash + broker_id: broker-id +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: 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/TestSetUserID/Successfully_set_UID_if_ID_is_already_in_use_as_GID_of_authd_user/db b/internal/users/testdata/golden/TestSetUserID/Successfully_set_UID_if_ID_is_already_in_use_as_GID_of_authd_user/db new file mode 100644 index 0000000000..47c33227f9 --- /dev/null +++ b/internal/users/testdata/golden/TestSetUserID/Successfully_set_UID_if_ID_is_already_in_use_as_GID_of_authd_user/db @@ -0,0 +1,60 @@ +users: + - 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 + - name: user1 + uid: 22222 + gid: 11111 + gecos: |- + User1 gecos + On multiple lines + dir: /tmp/home/user-1111 + shell: /bin/bash + broker_id: broker-id +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: 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/TestSetUserID/Successfully_set_UID_if_ID_is_already_in_use_as_GID_of_system_user/db b/internal/users/testdata/golden/TestSetUserID/Successfully_set_UID_if_ID_is_already_in_use_as_GID_of_system_user/db new file mode 100644 index 0000000000..6ebb794493 --- /dev/null +++ b/internal/users/testdata/golden/TestSetUserID/Successfully_set_UID_if_ID_is_already_in_use_as_GID_of_system_user/db @@ -0,0 +1,60 @@ +users: + - 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 + - name: user1 + uid: 54321 + gid: 11111 + gecos: |- + User1 gecos + On multiple lines + dir: /tmp/home/user-1111 + shell: /bin/bash + broker_id: broker-id +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: 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/TestSetUserID/Successfully_set_UID_when_home_directory_does_not_exist/db b/internal/users/testdata/golden/TestSetUserID/Successfully_set_UID_when_home_directory_does_not_exist/db new file mode 100644 index 0000000000..0d8c08d612 --- /dev/null +++ b/internal/users/testdata/golden/TestSetUserID/Successfully_set_UID_when_home_directory_does_not_exist/db @@ -0,0 +1,64 @@ +users: + - 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 + - name: user1 + uid: 54321 + 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" + - 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: 2222 + gid: 22222 + - uid: 2222 + gid: 99999 + - uid: 3333 + gid: 33333 + - uid: 3333 + gid: 99999 + - uid: 4444 + gid: 44444 + - uid: 4444 + gid: 99999 + - uid: 54321 + gid: 11111 + - uid: 54321 + gid: 99999 +schema_version: 2 diff --git a/internal/users/testdata/golden/TestSetUserID/Warning_if_home_directory_cannot_be_accessed/db b/internal/users/testdata/golden/TestSetUserID/Warning_if_home_directory_cannot_be_accessed/db new file mode 100644 index 0000000000..6ebb794493 --- /dev/null +++ b/internal/users/testdata/golden/TestSetUserID/Warning_if_home_directory_cannot_be_accessed/db @@ -0,0 +1,60 @@ +users: + - 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 + - name: user1 + uid: 54321 + gid: 11111 + gecos: |- + User1 gecos + On multiple lines + dir: /tmp/home/user-1111 + shell: /bin/bash + broker_id: broker-id +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: 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/TestSetUserID/Warning_if_home_directory_cannot_be_accessed/warnings b/internal/users/testdata/golden/TestSetUserID/Warning_if_home_directory_cannot_be_accessed/warnings new file mode 100644 index 0000000000..27da56a848 --- /dev/null +++ b/internal/users/testdata/golden/TestSetUserID/Warning_if_home_directory_cannot_be_accessed/warnings @@ -0,0 +1 @@ +- Could not get owner of home directory "{{HOME}}" diff --git a/internal/users/testdata/golden/TestSetUserID/Warning_if_home_directory_is_owned_by_other_user/db b/internal/users/testdata/golden/TestSetUserID/Warning_if_home_directory_is_owned_by_other_user/db new file mode 100644 index 0000000000..9fb004e951 --- /dev/null +++ b/internal/users/testdata/golden/TestSetUserID/Warning_if_home_directory_is_owned_by_other_user/db @@ -0,0 +1,60 @@ +users: + - 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 + - name: user1 + uid: 54321 + gid: 11111 + gecos: |- + User1 gecos + On multiple lines + dir: /tmp/home/user-2222 + shell: /bin/bash + broker_id: broker-id +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: 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/TestSetUserID/Warning_if_home_directory_is_owned_by_other_user/warnings b/internal/users/testdata/golden/TestSetUserID/Warning_if_home_directory_is_owned_by_other_user/warnings new file mode 100644 index 0000000000..f20b567a3e --- /dev/null +++ b/internal/users/testdata/golden/TestSetUserID/Warning_if_home_directory_is_owned_by_other_user/warnings @@ -0,0 +1 @@ +- 'Not changing ownership of home directory "{{HOME}}", because it is not owned by UID {{UID}} (current owner: {{CURR_UID}})' diff --git a/internal/users/testdata/golden/TestSetUserID/Warning_if_user_already_has_given_UID/db b/internal/users/testdata/golden/TestSetUserID/Warning_if_user_already_has_given_UID/db new file mode 100644 index 0000000000..6ebb794493 --- /dev/null +++ b/internal/users/testdata/golden/TestSetUserID/Warning_if_user_already_has_given_UID/db @@ -0,0 +1,60 @@ +users: + - 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 + - name: user1 + uid: 54321 + gid: 11111 + gecos: |- + User1 gecos + On multiple lines + dir: /tmp/home/user-1111 + shell: /bin/bash + broker_id: broker-id +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: 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/TestSetUserID/Warning_if_user_already_has_given_UID/warnings b/internal/users/testdata/golden/TestSetUserID/Warning_if_user_already_has_given_UID/warnings new file mode 100644 index 0000000000..9633aafca3 --- /dev/null +++ b/internal/users/testdata/golden/TestSetUserID/Warning_if_user_already_has_given_UID/warnings @@ -0,0 +1 @@ +- User "user1" already has UID 54321 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 index 87139b9940..0c6f18749f 100644 --- 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 @@ -51,31 +51,6 @@ 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} > ──────────────────────────────────────────────────────────────────────────────── diff --git a/pam/integration-tests/testdata/golden/TestSSHAuthenticate/Authenticate_user_locks_and_unlocks_it_with_shared_sshd b/pam/integration-tests/testdata/golden/TestSSHAuthenticate/Authenticate_user_locks_and_unlocks_it_with_shared_sshd index 9ebba360c1..422a908c70 100644 --- a/pam/integration-tests/testdata/golden/TestSSHAuthenticate/Authenticate_user_locks_and_unlocks_it_with_shared_sshd +++ b/pam/integration-tests/testdata/golden/TestSSHAuthenticate/Authenticate_user_locks_and_unlocks_it_with_shared_sshd @@ -51,31 +51,6 @@ 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} > ──────────────────────────────────────────────────────────────────────────────── 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 index c736c096f2..fce122641b 100644 --- a/pam/integration-tests/testdata/tapes/ssh/simple_auth_locks_unlocks.tape +++ b/pam/integration-tests/testdata/tapes/ssh/simple_auth_locks_unlocks.tape @@ -21,22 +21,6 @@ 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