From 600359805fbe7dbe9b9f6e60f5ba2699dfca267e Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Fri, 21 Nov 2025 11:05:23 +0100 Subject: [PATCH 01/49] refactor: Split cmd/authctl/user/user_test.go --- cmd/authctl/user/lock_test.go | 58 +++++++++++++++++++++++++++++++++++ cmd/authctl/user/user_test.go | 47 ---------------------------- 2 files changed, 58 insertions(+), 47 deletions(-) create mode 100644 cmd/authctl/user/lock_test.go diff --git a/cmd/authctl/user/lock_test.go b/cmd/authctl/user/lock_test.go new file mode 100644 index 0000000000..d7348984ed --- /dev/null +++ b/cmd/authctl/user/lock_test.go @@ -0,0 +1,58 @@ +package user_test + +import ( + "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" +) + +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) + }) + } +} diff --git a/cmd/authctl/user/user_test.go b/cmd/authctl/user/user_test.go index 3d2530bb2c..7951463545 100644 --- a/cmd/authctl/user/user_test.go +++ b/cmd/authctl/user/user_test.go @@ -4,14 +4,11 @@ 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 @@ -57,50 +54,6 @@ func TestUserCommand(t *testing.T) { } } -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) - }) - } -} - func TestMain(m *testing.M) { var authctlCleanup func() var err error From 94065d3bd193f32ae9d69687a3476badf97e0113 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 17 Jun 2025 11:41:23 +0200 Subject: [PATCH 02/49] Fix comment There's nothing encrypted in this string. --- internal/services/permissions/servercreds.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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) } From a7875554c2e3f54e10610921bbc0f42caf591c96 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 17 Jun 2025 12:22:41 +0200 Subject: [PATCH 03/49] Add gRPC method SetUserID --- internal/proto/authd/authd.pb.go | 278 ++++++++++++++---- internal/proto/authd/authd.proto | 15 + internal/proto/authd/authd_grpc.pb.go | 38 +++ .../testdata/golden/TestRegisterGRPCServices | 3 + internal/services/user/user.go | 22 ++ internal/users/db/update.go | 68 +++++ internal/users/manager.go | 48 +++ 7 files changed, 414 insertions(+), 58 deletions(-) diff --git a/internal/proto/authd/authd.pb.go b/internal/proto/authd/authd.pb.go index 66bcb4bd03..89232fe5e4 100644 --- a/internal/proto/authd/authd.pb.go +++ b/internal/proto/authd/authd.pb.go @@ -1169,6 +1169,154 @@ 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"` + 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 +} + +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"` + 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 +} + type User struct { state protoimpl.MessageState `protogen:"open.v1"` Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` @@ -1183,7 +1331,7 @@ type User struct { func (x *User) Reset() { *x = User{} - mi := &file_authd_proto_msgTypes[22] + mi := &file_authd_proto_msgTypes[25] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1195,7 +1343,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[25] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1208,7 +1356,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{25} } func (x *User) GetName() string { @@ -1262,7 +1410,7 @@ type Users struct { func (x *Users) Reset() { *x = Users{} - mi := &file_authd_proto_msgTypes[23] + mi := &file_authd_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1274,7 +1422,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[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1287,7 +1435,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{26} } func (x *Users) GetUsers() []*User { @@ -1310,7 +1458,7 @@ type Group struct { func (x *Group) Reset() { *x = Group{} - mi := &file_authd_proto_msgTypes[24] + mi := &file_authd_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1322,7 +1470,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[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1335,7 +1483,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{27} } func (x *Group) GetName() string { @@ -1375,7 +1523,7 @@ type Groups struct { func (x *Groups) Reset() { *x = Groups{} - mi := &file_authd_proto_msgTypes[25] + mi := &file_authd_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1387,7 +1535,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[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1400,7 +1548,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{28} } func (x *Groups) GetGroups() []*Group { @@ -1421,7 +1569,7 @@ type ABResponse_BrokerInfo struct { func (x *ABResponse_BrokerInfo) Reset() { *x = ABResponse_BrokerInfo{} - mi := &file_authd_proto_msgTypes[26] + mi := &file_authd_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1433,7 +1581,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[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1480,7 +1628,7 @@ type GAMResponse_AuthenticationMode struct { func (x *GAMResponse_AuthenticationMode) Reset() { *x = GAMResponse_AuthenticationMode{} - mi := &file_authd_proto_msgTypes[27] + mi := &file_authd_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1492,7 +1640,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[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1537,7 +1685,7 @@ type IARequest_AuthenticationData struct { func (x *IARequest_AuthenticationData) Reset() { *x = IARequest_AuthenticationData{} - mi := &file_authd_proto_msgTypes[28] + mi := &file_authd_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1549,7 +1697,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[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1736,7 +1884,15 @@ 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\"6\n" + + "\x10SetUserIDRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x0e\n" + + "\x02id\x18\x02 \x01(\rR\x02id\"/\n" + + "\x11SetUserIDResponse\x12\x1a\n" + + "\bwarnings\x18\x01 \x03(\tR\bwarnings\"7\n" + + "\x11SetGroupIDRequest\x12\x12\n" + + "\x04name\x18\x01 \x01(\tR\x04name\x12\x0e\n" + + "\x02id\x18\x02 \x01(\rR\x02id\"\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 +1922,15 @@ 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\xf3\x03\n" + "\vUserService\x129\n" + "\rGetUserByName\x12\x1b.authd.GetUserByNameRequest\x1a\v.authd.User\x125\n" + "\vGetUserByID\x12\x19.authd.GetUserByIDRequest\x1a\v.authd.User\x12'\n" + "\tListUsers\x12\f.authd.Empty\x1a\f.authd.Users\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\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 +1949,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, 32) var file_authd_proto_goTypes = []any{ (SessionMode)(0), // 0: authd.SessionMode (*Empty)(nil), // 1: authd.Empty @@ -1817,23 +1974,26 @@ 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 + (*User)(nil), // 26: authd.User + (*Users)(nil), // 27: authd.Users + (*Group)(nil), // 28: authd.Group + (*Groups)(nil), // 29: authd.Groups + (*ABResponse_BrokerInfo)(nil), // 30: authd.ABResponse.BrokerInfo + (*GAMResponse_AuthenticationMode)(nil), // 31: authd.GAMResponse.AuthenticationMode + (*IARequest_AuthenticationData)(nil), // 32: authd.IARequest.AuthenticationData } var file_authd_proto_depIdxs = []int32{ - 27, // 0: authd.ABResponse.brokers_infos:type_name -> authd.ABResponse.BrokerInfo + 30, // 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 + 31, // 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 + 32, // 5: authd.IARequest.authentication_data:type_name -> authd.IARequest.AuthenticationData + 26, // 6: authd.Users.users:type_name -> authd.User + 28, // 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 +2007,29 @@ 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 + 21, // 22: authd.UserService.GetGroupByName:input_type -> authd.GetGroupByNameRequest + 22, // 23: authd.UserService.GetGroupByID:input_type -> authd.GetGroupByIDRequest + 1, // 24: authd.UserService.ListGroups:input_type -> authd.Empty + 4, // 25: authd.PAM.AvailableBrokers:output_type -> authd.ABResponse + 3, // 26: authd.PAM.GetPreviousBroker:output_type -> authd.GPBResponse + 7, // 27: authd.PAM.SelectBroker:output_type -> authd.SBResponse + 10, // 28: authd.PAM.GetAuthenticationModes:output_type -> authd.GAMResponse + 12, // 29: authd.PAM.SelectAuthenticationMode:output_type -> authd.SAMResponse + 14, // 30: authd.PAM.IsAuthenticated:output_type -> authd.IAResponse + 1, // 31: authd.PAM.EndSession:output_type -> authd.Empty + 1, // 32: authd.PAM.SetDefaultBrokerForUser:output_type -> authd.Empty + 26, // 33: authd.UserService.GetUserByName:output_type -> authd.User + 26, // 34: authd.UserService.GetUserByID:output_type -> authd.User + 27, // 35: authd.UserService.ListUsers:output_type -> authd.Users + 1, // 36: authd.UserService.LockUser:output_type -> authd.Empty + 1, // 37: authd.UserService.UnlockUser:output_type -> authd.Empty + 24, // 38: authd.UserService.SetUserID:output_type -> authd.SetUserIDResponse + 28, // 39: authd.UserService.GetGroupByName:output_type -> authd.Group + 28, // 40: authd.UserService.GetGroupByID:output_type -> authd.Group + 29, // 41: authd.UserService.ListGroups:output_type -> authd.Groups + 25, // [25:42] is the sub-list for method output_type + 8, // [8:25] 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 +2041,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[29].OneofWrappers = []any{} + file_authd_proto_msgTypes[31].OneofWrappers = []any{ (*IARequest_AuthenticationData_Secret)(nil), (*IARequest_AuthenticationData_Wait)(nil), (*IARequest_AuthenticationData_Skip)(nil), @@ -1892,7 +2054,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: 32, NumExtensions: 0, NumServices: 2, }, diff --git a/internal/proto/authd/authd.proto b/internal/proto/authd/authd.proto index 8aa3bd072c..e0e4f52c0f 100644 --- a/internal/proto/authd/authd.proto +++ b/internal/proto/authd/authd.proto @@ -135,6 +135,7 @@ service UserService { rpc ListUsers(Empty) returns (Users); rpc LockUser(LockUserRequest) returns (Empty); rpc UnlockUser(UnlockUserRequest) returns (Empty); + rpc SetUserID(SetUserIDRequest) returns (SetUserIDResponse); rpc GetGroupByName(GetGroupByNameRequest) returns (Group); rpc GetGroupByID(GetGroupByIDRequest) returns (Group); @@ -166,6 +167,20 @@ message GetGroupByIDRequest{ uint32 id = 1; } +message SetUserIDRequest { + string name = 1; + uint32 id = 2; +} + +message SetUserIDResponse { + repeated string warnings = 1; +} + +message SetGroupIDRequest { + string name = 1; + uint32 id = 2; +} + 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..0722db3ed2 100644 --- a/internal/proto/authd/authd_grpc.pb.go +++ b/internal/proto/authd/authd_grpc.pb.go @@ -392,6 +392,7 @@ 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_GetGroupByName_FullMethodName = "/authd.UserService/GetGroupByName" UserService_GetGroupByID_FullMethodName = "/authd.UserService/GetGroupByID" UserService_ListGroups_FullMethodName = "/authd.UserService/ListGroups" @@ -406,6 +407,7 @@ 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) 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 +471,16 @@ 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) GetGroupByName(ctx context.Context, in *GetGroupByNameRequest, opts ...grpc.CallOption) (*Group, error) { cOpts := append([]grpc.CallOption{grpc.StaticMethod()}, opts...) out := new(Group) @@ -508,6 +520,7 @@ 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) GetGroupByName(context.Context, *GetGroupByNameRequest) (*Group, error) GetGroupByID(context.Context, *GetGroupByIDRequest) (*Group, error) ListGroups(context.Context, *Empty) (*Groups, error) @@ -536,6 +549,9 @@ 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.Errorf(codes.Unimplemented, "method SetUserID not implemented") +} func (UnimplementedUserServiceServer) GetGroupByName(context.Context, *GetGroupByNameRequest) (*Group, error) { return nil, status.Error(codes.Unimplemented, "method GetGroupByName not implemented") } @@ -656,6 +672,24 @@ 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_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 +771,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{ MethodName: "UnlockUser", Handler: _UserService_UnlockUser_Handler, }, + { + MethodName: "SetUserID", + Handler: _UserService_SetUserID_Handler, + }, { MethodName: "GetGroupByName", Handler: _UserService_GetGroupByName_Handler, diff --git a/internal/services/testdata/golden/TestRegisterGRPCServices b/internal/services/testdata/golden/TestRegisterGRPCServices index ad43546e3c..b4e4c3df84 100644 --- a/internal/services/testdata/golden/TestRegisterGRPCServices +++ b/internal/services/testdata/golden/TestRegisterGRPCServices @@ -48,6 +48,9 @@ authd.UserService: - name: LockUser 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..fa88bc38dd 100644 --- a/internal/services/user/user.go +++ b/internal/services/user/user.go @@ -218,6 +218,28 @@ 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, err + } + + // 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 +} + // userToProtobuf converts a types.UserEntry to authd.User. func userToProtobuf(u types.UserEntry) *authd.User { return &authd.User{ diff --git a/internal/users/db/update.go b/internal/users/db/update.go index 76de951b66..242c6ab7db 100644 --- a/internal/users/db/update.go +++ b/internal/users/db/update.go @@ -193,3 +193,71 @@ 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 +} diff --git a/internal/users/manager.go b/internal/users/manager.go index 84b763aa2b..c844b81b20 100644 --- a/internal/users/manager.go +++ b/internal/users/manager.go @@ -7,13 +7,16 @@ import ( "fmt" "math" "os" + "os/user" "slices" + "strconv" "sync" "syscall" "github.com/canonical/authd/internal/decorate" "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/tempentries" "github.com/canonical/authd/internal/users/types" "github.com/canonical/authd/log" @@ -364,6 +367,51 @@ 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") + } + + // 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 already has the given UID + oldUser, err := m.db.UserByName(name) + if err != nil { + return nil, err + } + 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) + } + + err = m.db.SetUserID(name, uid) + if err != nil { + return nil, err + } + + return nil, 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 { From 1747b5732588ae0736bff00be32bab860203efbb Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 17 Jun 2025 12:29:35 +0200 Subject: [PATCH 04/49] Add `authctl user set-uid` command --- cmd/authctl/user/set-uid.go | 46 +++++++++++++++++++ .../TestUserCommand/Error_on_invalid_command | 1 + .../TestUserCommand/Error_on_invalid_flag | 1 + .../testdata/golden/TestUserCommand/Help_flag | 1 + .../Usage_message_when_no_args | 1 + cmd/authctl/user/user.go | 1 + 6 files changed, 51 insertions(+) create mode 100644 cmd/authctl/user/set-uid.go diff --git a/cmd/authctl/user/set-uid.go b/cmd/authctl/user/set-uid.go new file mode 100644 index 0000000000..107152b859 --- /dev/null +++ b/cmd/authctl/user/set-uid.go @@ -0,0 +1,46 @@ +package user + +import ( + "context" + "errors" + "fmt" + "strconv" + + "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", + Args: cobra.ExactArgs(2), + 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 := NewUserServiceClient() + if err != nil { + return err + } + + _, err = client.SetUserID(context.Background(), &authd.SetUserIDRequest{ + Name: name, + Id: uint32(uid), + }) + if err != nil { + return err + } + + return nil + }, +} 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/user.go b/cmd/authctl/user/user.go index b0e371c9b9..1552222408 100644 --- a/cmd/authctl/user/user.go +++ b/cmd/authctl/user/user.go @@ -46,4 +46,5 @@ func NewUserServiceClient() (authd.UserServiceClient, error) { func init() { UserCmd.AddCommand(lockCmd) UserCmd.AddCommand(unlockCmd) + UserCmd.AddCommand(setUIDCmd) } From 2e24ad833c67de982a002fd8ee479d7f61bbcec0 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 17 Jun 2025 13:38:01 +0200 Subject: [PATCH 05/49] Return a gRPC error with code PermissionDenied ... instead of prefixing the error message with "permission denied" --- .../TestIsAuthenticated/Error_when_not_root/IsAuthenticated | 2 +- internal/services/permissions.go | 6 ++++-- internal/services/permissions/permissions.go | 3 --- internal/services/user/user.go | 6 +++--- 4 files changed, 8 insertions(+), 9 deletions(-) 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/user/user.go b/internal/services/user/user.go index fa88bc38dd..785aaedf68 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. @@ -221,7 +221,7 @@ func (s Service) ListGroups(ctx context.Context, req *authd.Empty) (*authd.Group // 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, err + return nil, status.Error(codes.PermissionDenied, err.Error()) } // authd uses lowercase usernames. From 82a571a65e84b5ba6d94314600bcd5e4fe2c0073 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Wed, 18 Jun 2025 01:19:27 +0200 Subject: [PATCH 06/49] debian/authd.service.in: Grant capability CAP_DAC_READ_SEARCH Required to change the ownership of the user's home directory when changing the user's UID. --- debian/authd.service.in | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/debian/authd.service.in b/debian/authd.service.in index e8fec012eb..299faa8e7c 100644 --- a/debian/authd.service.in +++ b/debian/authd.service.in @@ -86,5 +86,9 @@ 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. +CapabilityBoundingSet=CAP_CHOWN CAP_DAC_READ_SEARCH From 99e55e94ff9629133ba6ff00ab49e48da4867311 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Wed, 18 Jun 2025 01:20:38 +0200 Subject: [PATCH 07/49] fileutils: Add ChownRecursiveFrom We use this to recursively change the owner and group of the user's home directory when changing the user's UID. --- internal/fileutils/fileutils.go | 46 +++++++++++++++++++ .../No_change_if_no_file_matches_fromGID | 3 ++ .../No_change_if_no_file_matches_fromUID | 3 ++ .../No_change_if_ownership_is_same_as_current | 3 ++ ...hange_if_toUID_and_toGID_are_both_negative | 3 ++ .../Successfully_change_group | 3 ++ .../Successfully_change_owner | 3 ++ .../Successfully_change_owner_and_group | 3 ++ 8 files changed, 67 insertions(+) create mode 100644 internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_no_file_matches_fromGID create mode 100644 internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_no_file_matches_fromUID create mode 100644 internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_ownership_is_same_as_current create mode 100644 internal/fileutils/testdata/golden/TestChownRecursiveFrom/No_change_if_toUID_and_toGID_are_both_negative create mode 100644 internal/fileutils/testdata/golden/TestChownRecursiveFrom/Successfully_change_group create mode 100644 internal/fileutils/testdata/golden/TestChownRecursiveFrom/Successfully_change_owner create mode 100644 internal/fileutils/testdata/golden/TestChownRecursiveFrom/Successfully_change_owner_and_group 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/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/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 From 09de1bd709d3b941719f5bbcb2a3ac865554e094 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Thu, 20 Nov 2025 19:58:57 +0100 Subject: [PATCH 08/49] testutils: Add RunTestAsRoot Needed to test fileutils.ChownRecursiveFrom. We can't use bubblewrap for that because bubblewrap only creates UID mapping for one user, using chown with a different UID fails with: chown: changing ownership of 'file': Invalid argument --- internal/testutils/bubblewrap.go | 8 ++- internal/testutils/root.go | 91 ++++++++++++++++++++++++++++++++ 2 files changed, 94 insertions(+), 5 deletions(-) create mode 100644 internal/testutils/root.go diff --git a/internal/testutils/bubblewrap.go b/internal/testutils/bubblewrap.go index ec39e701cb..a176a1c85a 100644 --- a/internal/testutils/bubblewrap.go +++ b/internal/testutils/bubblewrap.go @@ -45,7 +45,7 @@ func SkipIfCannotRunBubbleWrap(t *testing.T) { } bubbleWrapNeedsSudoOnce.Do(func() { - bubbleWrapNeedsSudo = canUseSudoNonInteractively(t) + bubbleWrapNeedsSudo = canUseBwrapWithSudoNonInteractively(t) }) if bubbleWrapNeedsSudo { return @@ -168,12 +168,10 @@ func canUseUnprivilegedUserNamespaces(t *testing.T) bool { 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 } diff --git a/internal/testutils/root.go b/internal/testutils/root.go new file mode 100644 index 0000000000..ce03ea658f --- /dev/null +++ b/internal/testutils/root.go @@ -0,0 +1,91 @@ +package testutils + +import ( + "fmt" + "os" + "os/exec" + "sync" + "testing" + + "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...) + + t.Logf("Running %s as root", t.Name()) + err := runSudoCommand(t, args...) + if err != nil { + t.Fatalf("Failed to run test %s as root: %v", t.Name(), err) + } +} + +func runSudoCommand(t *testing.T, args ...string) error { + t.Helper() + + sudoArgs := append([]string{"-n"}, args...) + //nolint:gosec // G204 we want to use exec.Command with variables here + cmd := exec.Command("sudo", sudoArgs...) + cmd.Stdout = os.Stdout + cmd.Stderr = os.Stderr + t.Log("Running command:", cmd.String()) + return cmd.Run() +} + +func canUseSudoNonInteractively(t *testing.T) bool { + t.Helper() + + t.Log("Checking if we can use sudo non-interactively") + + cmd := exec.Command("sudo", "-n", "true") + if out, err := cmd.CombinedOutput(); err != nil { + t.Logf("Can't use sudo non-interactively: %v\n%s", err, out) + return false + } + return true +} From 9632666e05f8de2ffb7bfefb6ca9f09ee2286d5e Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Thu, 20 Nov 2025 20:03:15 +0100 Subject: [PATCH 09/49] Add TestChownRecursiveFrom --- internal/fileutils/fileutils_test.go | 135 ++++++++++++++++++ .../Change_only_the_directory_group | 3 + .../Change_only_the_directory_owner | 3 + .../Change_only_the_directory_owner_and_group | 3 + .../Change_only_the_file_group | 3 + .../Change_only_the_file_owner | 3 + .../Change_only_the_file_owner_and_group | 3 + .../Group_not_changed_when_GID_does_not_match | 3 + .../Owner_not_changed_when_UID_does_not_match | 3 + internal/testutils/golden/golden.go | 18 ++- 10 files changed, 171 insertions(+), 6 deletions(-) create mode 100644 internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_directory_group create mode 100644 internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_directory_owner create mode 100644 internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_directory_owner_and_group create mode 100644 internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_file_group create mode 100644 internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_file_owner create mode 100644 internal/fileutils/testdata/golden/TestChownRecursiveFrom/Change_only_the_file_owner_and_group create mode 100644 internal/fileutils/testdata/golden/TestChownRecursiveFrom/Group_not_changed_when_GID_does_not_match create mode 100644 internal/fileutils/testdata/golden/TestChownRecursiveFrom/Owner_not_changed_when_UID_does_not_match diff --git a/internal/fileutils/fileutils_test.go b/internal/fileutils/fileutils_test.go index 49b413290c..9cac38e99f 100644 --- a/internal/fileutils/fileutils_test.go +++ b/internal/fileutils/fileutils_test.go @@ -2,13 +2,18 @@ package fileutils_test import ( "errors" + "fmt" "os" + "os/exec" "path/filepath" + "strconv" + "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 +401,133 @@ 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.RunningAsRoot() { + testutils.RunTestAsRoot(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) + + if golden.UpdateEnabled() { + // Change the owner of the golden file to the user that executed sudo + // to avoid permission issues. + sudoUID := os.Getenv("SUDO_UID") + sudoGID := os.Getenv("SUDO_GID") + if sudoUID != "" && sudoGID != "" { + uid, err := strconv.ParseInt(sudoUID, 10, 32) + require.NoError(t, err) + gid, err := strconv.ParseInt(sudoGID, 10, 32) + require.NoError(t, err) + err = fileutils.ChownRecursiveFrom(golden.Dir(t), 0, 0, int32(uid), int32(gid)) + require.NoError(t, err) + } + } + }) + } +} 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/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/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. From 85033ba806be38d228b8980bfdbffd88cb2899a9 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Wed, 18 Jun 2025 01:25:49 +0200 Subject: [PATCH 10/49] Automatically change owner of home directory when changing UID Do the same usermod does when changing a UID of a user: If the home directory is currently owned by the user, recursively change the owner and group of the home directory and all files in the home directory from the old UID and GID to the new UID and GID. --- cmd/authctl/user/set-uid.go | 8 +++++- internal/users/manager.go | 56 ++++++++++++++++++++++++++++++------- 2 files changed, 53 insertions(+), 11 deletions(-) diff --git a/cmd/authctl/user/set-uid.go b/cmd/authctl/user/set-uid.go index 107152b859..903de257c9 100644 --- a/cmd/authctl/user/set-uid.go +++ b/cmd/authctl/user/set-uid.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "os" "strconv" "github.com/canonical/authd/internal/proto/authd" @@ -33,7 +34,7 @@ var setUIDCmd = &cobra.Command{ return err } - _, err = client.SetUserID(context.Background(), &authd.SetUserIDRequest{ + resp, err := client.SetUserID(context.Background(), &authd.SetUserIDRequest{ Name: name, Id: uint32(uid), }) @@ -41,6 +42,11 @@ var setUIDCmd = &cobra.Command{ 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/internal/users/manager.go b/internal/users/manager.go index c844b81b20..519cd91756 100644 --- a/internal/users/manager.go +++ b/internal/users/manager.go @@ -14,6 +14,7 @@ import ( "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" @@ -317,7 +318,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) } @@ -375,6 +376,10 @@ func (m *Manager) SetUserID(name string, uid uint32) (warnings []string, err err return nil, errors.New("empty username") } + if uid > math.MaxInt32 { + return nil, fmt.Errorf("UID %d is too large to convert to int32", uid) + } + // Call lckpwdf to avoid race conditions with other processes which add UIDs err = userslocking.WriteLock() if err != nil { @@ -409,6 +414,29 @@ func (m *Manager) SetUserID(name string, uid uint32) (warnings []string, err err 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) { + return nil, fmt.Errorf("failed to get home directory owner for user %q: %w", name, err) + } + 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. + err = fileutils.ChownRecursiveFrom(oldUser.Dir, oldUser.UID, 0, int32(uid), -1) + if err != nil { + return nil, err + } + return nil, nil } @@ -457,10 +485,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 } @@ -469,12 +511,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) From 5b8a21886fc12eaefd37648c458de8cc71341629 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Wed, 12 Nov 2025 14:26:07 +0100 Subject: [PATCH 11/49] Add TestSetUIDCommand --- cmd/authctl/user/set-uid_test.go | 101 ++++++++++++++++++ .../Error_when_authd_is_unavailable | 1 + .../Error_when_uid_is_already_taken | 1 + .../Error_when_uid_is_invalid | 1 + .../Error_when_uid_is_negative | 7 ++ .../Error_when_uid_is_too_large | 1 + .../Error_when_user_does_not_exist | 1 + .../TestSetUIDCommand/Set_user_uid_success | 0 8 files changed, 113 insertions(+) create mode 100644 cmd/authctl/user/set-uid_test.go create mode 100644 cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_authd_is_unavailable create mode 100644 cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_already_taken create mode 100644 cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_invalid create mode 100644 cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_negative create mode 100644 cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_uid_is_too_large create mode 100644 cmd/authctl/user/testdata/golden/TestSetUIDCommand/Error_when_user_does_not_exist create mode 100644 cmd/authctl/user/testdata/golden/TestSetUIDCommand/Set_user_uid_success diff --git a/cmd/authctl/user/set-uid_test.go b/cmd/authctl/user/set-uid_test.go new file mode 100644 index 0000000000..5b025ae5a6 --- /dev/null +++ b/cmd/authctl/user/set-uid_test.go @@ -0,0 +1,101 @@ +package user_test + +import ( + "math" + "os" + "os/exec" + "path/filepath" + "strconv" + "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" +) + +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...)...) + 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) + }) + } +} 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 From a10ce88f4971d4f1099ccd6bb43075da78bc851b Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Mon, 17 Nov 2025 15:19:21 +0100 Subject: [PATCH 12/49] Add db_test.TestSetUserID --- internal/users/db/db_test.go | 70 +++++++++++++++++++ .../TestSetUserID/No_op_if_uid_is_already_set | 64 +++++++++++++++++ .../Set_user_id_for_existing_user | 64 +++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 internal/users/db/testdata/golden/TestSetUserID/No_op_if_uid_is_already_set create mode 100644 internal/users/db/testdata/golden/TestSetUserID/Set_user_id_for_existing_user diff --git a/internal/users/db/db_test.go b/internal/users/db/db_test.go index dda644fc03..22e856bcfd 100644 --- a/internal/users/db/db_test.go +++ b/internal/users/db/db_test.go @@ -866,6 +866,76 @@ 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 TestRemoveDb(t *testing.T) { t.Parallel() 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 From 07f0860804eb2895ae3f31576bd2e1da1bfed49b Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 18 Nov 2025 13:17:05 +0100 Subject: [PATCH 13/49] Support creating users/groups in bubblewrap We need that for the SetUserID tests --- internal/testutils/bubblewrap.go | 28 +++++++--------------------- 1 file changed, 7 insertions(+), 21 deletions(-) diff --git a/internal/testutils/bubblewrap.go b/internal/testutils/bubblewrap.go index a176a1c85a..1c09c1694a 100644 --- a/internal/testutils/bubblewrap.go +++ b/internal/testutils/bubblewrap.go @@ -94,7 +94,13 @@ func runInBubbleWrap(t *testing.T, withSudo bool, testDataPath string, env []str etcDir := filepath.Join(testDataPath, "etc") err := os.MkdirAll(etcDir, 0700) - require.NoError(t, err, "Impossible to create /etc") + require.NoError(t, err, "Setup: could not create etc dir") + + // 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) + } cmd.Args = append(cmd.Args, "--ro-bind", "/", "/", @@ -111,9 +117,6 @@ 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", @@ -121,23 +124,6 @@ func runInBubbleWrap(t *testing.T, withSudo bool, testDataPath string, env []str "--ro-bind", "/etc/security", "/etc/security", ) - 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) - } - - // 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) - } - if coverDir := CoverDirForTests(); coverDir != "" { cmd.Args = append(cmd.Args, "--bind", coverDir, coverDir) } From 3fa2dc259c3c3782e93508e90c5587717a37db00 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Thu, 27 Nov 2025 18:01:16 +0100 Subject: [PATCH 14/49] tests: Support chown in bubblewrap --- debian/control | 1 + internal/testutils/bubblewrap.go | 28 +++++++++++++++++++++++----- 2 files changed, 24 insertions(+), 5 deletions(-) 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/internal/testutils/bubblewrap.go b/internal/testutils/bubblewrap.go index 1c09c1694a..cca1b1a765 100644 --- a/internal/testutils/bubblewrap.go +++ b/internal/testutils/bubblewrap.go @@ -18,6 +18,9 @@ var ( bubbleWrapNeedsSudoOnce sync.Once bubbleWrapNeedsSudo bool + + copyBwrapOnce sync.Once + copiedBwrapPath string ) const bubbleWrapTestEnvVar = "BUBBLEWRAP_TEST" @@ -79,7 +82,26 @@ func RunTestInBubbleWrap(t *testing.T, args ...string) { func runInBubbleWrap(t *testing.T, withSudo bool, testDataPath string, env []string, args ...string) error { t.Helper() - cmd := exec.Command("bwrap") + // 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") + }) + + // 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-auto", + copiedBwrapPath) + cmd.Env = AppendCovEnv(os.Environ()) cmd.Env = append(cmd.Env, env...) cmd.Env = append(cmd.Env, bubbleWrapTestEnvVar+"=1") @@ -128,10 +150,6 @@ func runInBubbleWrap(t *testing.T, withSudo bool, testDataPath string, env []str cmd.Args = append(cmd.Args, "--bind", coverDir, coverDir) } - if os.Geteuid() != 0 && !withSudo { - cmd.Args = append(cmd.Args, "--unshare-user", "--uid", "0") - } - cmd.Args = append(cmd.Args, args...) cmd.Stderr = t.Output() cmd.Stdout = t.Output() From 62398155d261f55cb599ff54df8764cb028e071e Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Mon, 1 Dec 2025 15:10:55 +0100 Subject: [PATCH 15/49] tests: Run TestChownRecursiveFrom in bubblewrap We now support chown in bubblewrap, so we don't have to run the test as root anymore. --- internal/fileutils/fileutils_test.go | 20 ++------------------ 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/internal/fileutils/fileutils_test.go b/internal/fileutils/fileutils_test.go index 9cac38e99f..56d87e432f 100644 --- a/internal/fileutils/fileutils_test.go +++ b/internal/fileutils/fileutils_test.go @@ -6,7 +6,6 @@ import ( "os" "os/exec" "path/filepath" - "strconv" "syscall" "testing" "time" @@ -443,8 +442,8 @@ func TestChownRecursiveFrom(t *testing.T) { t.Run(name, func(t *testing.T) { t.Parallel() - if !testutils.RunningAsRoot() { - testutils.RunTestAsRoot(t) + if !testutils.RunningInBubblewrap() { + testutils.RunTestInBubbleWrap(t) return } @@ -513,21 +512,6 @@ func TestChownRecursiveFrom(t *testing.T) { s += fmt.Sprintf("%s: %d:%d\n", relPath, stat.Uid, stat.Gid) } golden.CheckOrUpdate(t, s) - - if golden.UpdateEnabled() { - // Change the owner of the golden file to the user that executed sudo - // to avoid permission issues. - sudoUID := os.Getenv("SUDO_UID") - sudoGID := os.Getenv("SUDO_GID") - if sudoUID != "" && sudoGID != "" { - uid, err := strconv.ParseInt(sudoUID, 10, 32) - require.NoError(t, err) - gid, err := strconv.ParseInt(sudoGID, 10, 32) - require.NoError(t, err) - err = fileutils.ChownRecursiveFrom(golden.Dir(t), 0, 0, int32(uid), int32(gid)) - require.NoError(t, err) - } - } }) } } From d0d9b26f24ec1d19224949fb3e4915eea5168c53 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 2 Dec 2025 12:56:59 +0100 Subject: [PATCH 16/49] refactor: Remove unused parameter from runInBubbleWrap --- internal/testutils/bubblewrap.go | 15 +++++---------- 1 file changed, 5 insertions(+), 10 deletions(-) diff --git a/internal/testutils/bubblewrap.go b/internal/testutils/bubblewrap.go index cca1b1a765..bfcda0d855 100644 --- a/internal/testutils/bubblewrap.go +++ b/internal/testutils/bubblewrap.go @@ -73,13 +73,13 @@ func RunTestInBubbleWrap(t *testing.T, args ...string) { args = append(args, testCommand...) t.Logf("Running %s in bubblewrap", t.Name()) - err := runInBubbleWrap(t, bubbleWrapNeedsSudo, "", nil, args...) + 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 { +func runInBubbleWrap(t *testing.T, withSudo bool, env []string, args ...string) error { t.Helper() // Since 25.10 Ubuntu ships the AppArmor profile /etc/apparmor.d/bwrap-userns-restrict @@ -110,11 +110,7 @@ func runInBubbleWrap(t *testing.T, withSudo bool, testDataPath string, env []str cmd.Args = append([]string{"sudo"}, cmd.Args...) } - if testDataPath == "" { - testDataPath = TempDir(t) - } - - etcDir := filepath.Join(testDataPath, "etc") + etcDir := filepath.Join(TempDir(t), "etc") err := os.MkdirAll(etcDir, 0700) require.NoError(t, err, "Setup: could not create etc dir") @@ -128,7 +124,6 @@ func runInBubbleWrap(t *testing.T, withSudo bool, testDataPath string, env []str "--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 @@ -163,7 +158,7 @@ func canUseUnprivilegedUserNamespaces(t *testing.T) bool { t.Log("Checking if we can use unprivileged user namespaces") - if err := runInBubbleWrap(t, false, t.TempDir(), nil, "/bin/true"); err != nil { + if err := runInBubbleWrap(t, false, nil, "/bin/true"); err != nil { t.Logf("Can't use user namespaces: %v", err) return false } @@ -179,7 +174,7 @@ func canUseBwrapWithSudoNonInteractively(t *testing.T) bool { return false } - if err := runInBubbleWrap(t, true, t.TempDir(), nil, "/bin/true"); err != nil { + if err := runInBubbleWrap(t, true, nil, "/bin/true"); err != nil { t.Logf("Can't use bubblewrap with sudo: %v", err) return false } From 8eab98f6b8064478e1a5ebd2ac0e367146bd9a7d Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 2 Dec 2025 13:00:09 +0100 Subject: [PATCH 17/49] testutils/bubblewrap: Support updating golden files --- internal/testutils/bubblewrap.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/internal/testutils/bubblewrap.go b/internal/testutils/bubblewrap.go index bfcda0d855..5acac72be9 100644 --- a/internal/testutils/bubblewrap.go +++ b/internal/testutils/bubblewrap.go @@ -9,6 +9,7 @@ import ( "testing" "github.com/canonical/authd/internal/fileutils" + "github.com/canonical/authd/internal/testutils/golden" "github.com/stretchr/testify/require" ) @@ -145,6 +146,14 @@ func runInBubbleWrap(t *testing.T, withSudo bool, env []string, args ...string) cmd.Args = append(cmd.Args, "--bind", coverDir, coverDir) } + 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...) cmd.Stderr = t.Output() cmd.Stdout = t.Output() From 06f2bde15e7bb087a4777d2876d6c6ba73aeff08 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 2 Dec 2025 14:14:31 +0100 Subject: [PATCH 18/49] testutils: Skip cleanup when in bubblewrap --- internal/testutils/path.go | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) 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 From 5fa76fc6898dc89182ee28c040723bb80d123540 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 2 Dec 2025 21:03:04 +0100 Subject: [PATCH 19/49] bubblewrap: Mount a tmpfs to /tmp in the sandbox We have a use case where we want to create a directory at a deterministic path in /tmp. That fails if /tmp is shared with the host and other bubblewrap sandboxes which use the same directory. --- internal/testutils/bubblewrap.go | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/internal/testutils/bubblewrap.go b/internal/testutils/bubblewrap.go index 5acac72be9..9b376f697e 100644 --- a/internal/testutils/bubblewrap.go +++ b/internal/testutils/bubblewrap.go @@ -124,7 +124,7 @@ func runInBubbleWrap(t *testing.T, withSudo bool, env []string, args ...string) cmd.Args = append(cmd.Args, "--ro-bind", "/", "/", "--dev", "/dev", - "--bind", os.TempDir(), os.TempDir(), + "--tmpfs", "/tmp", "--bind", etcDir, "/etc", // Bind relevant etc files. We go manual here, since there's no @@ -140,6 +140,9 @@ func runInBubbleWrap(t *testing.T, withSudo bool, env []string, args ...string) "--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], ) if coverDir := CoverDirForTests(); coverDir != "" { From 024785f25160a740db9a34c039b8cf1810939419 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 2 Dec 2025 14:15:19 +0100 Subject: [PATCH 20/49] Add users_test.TestSetUserID --- internal/users/manager.go | 4 +- internal/users/manager_bwrap_test.go | 255 ++++++++++++++++++ internal/users/manager_test.go | 5 + .../TestSetUserID/Successfully_set_UID/db | 60 +++++ .../db | 60 +++++ .../db | 60 +++++ .../db | 64 +++++ .../db | 60 +++++ .../warnings | 1 + .../db | 60 +++++ .../warnings | 1 + .../Warning_if_user_already_has_given_UID/db | 60 +++++ .../warnings | 1 + 13 files changed, 690 insertions(+), 1 deletion(-) create mode 100644 internal/users/manager_bwrap_test.go create mode 100644 internal/users/testdata/golden/TestSetUserID/Successfully_set_UID/db create mode 100644 internal/users/testdata/golden/TestSetUserID/Successfully_set_UID_if_ID_is_already_in_use_as_GID_of_authd_user/db create mode 100644 internal/users/testdata/golden/TestSetUserID/Successfully_set_UID_if_ID_is_already_in_use_as_GID_of_system_user/db create mode 100644 internal/users/testdata/golden/TestSetUserID/Successfully_set_UID_when_home_directory_does_not_exist/db create mode 100644 internal/users/testdata/golden/TestSetUserID/Warning_if_home_directory_cannot_be_accessed/db create mode 100644 internal/users/testdata/golden/TestSetUserID/Warning_if_home_directory_cannot_be_accessed/warnings create mode 100644 internal/users/testdata/golden/TestSetUserID/Warning_if_home_directory_is_owned_by_other_user/db create mode 100644 internal/users/testdata/golden/TestSetUserID/Warning_if_home_directory_is_owned_by_other_user/warnings create mode 100644 internal/users/testdata/golden/TestSetUserID/Warning_if_user_already_has_given_UID/db create mode 100644 internal/users/testdata/golden/TestSetUserID/Warning_if_user_already_has_given_UID/warnings diff --git a/internal/users/manager.go b/internal/users/manager.go index 519cd91756..0b04f07801 100644 --- a/internal/users/manager.go +++ b/internal/users/manager.go @@ -417,7 +417,9 @@ func (m *Manager) SetUserID(name string, uid uint32) (warnings []string, err 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) { - return nil, fmt.Errorf("failed to get home directory owner for user %q: %w", name, err) + 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. diff --git a/internal/users/manager_bwrap_test.go b/internal/users/manager_bwrap_test.go new file mode 100644 index 0000000000..3dc32fb466 --- /dev/null +++ b/internal/users/manager_bwrap_test.go @@ -0,0 +1,255 @@ +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")) + }) + } +} + +// 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 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), + "--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..0a2d1b12fd 100644 --- a/internal/users/manager_test.go +++ b/internal/users/manager_test.go @@ -1317,6 +1317,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/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 From 2a49f7d324eec660b27d89af75a147b432377ca8 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 25 Nov 2025 18:18:20 +0100 Subject: [PATCH 21/49] Add SetGroupID --- internal/proto/authd/authd.pb.go | 169 ++++++++++++------ internal/proto/authd/authd.proto | 5 + internal/proto/authd/authd_grpc.pb.go | 40 ++++- .../testdata/golden/TestRegisterGRPCServices | 3 + internal/services/user/user.go | 22 +++ internal/users/db/update.go | 97 ++++++++++ internal/users/manager.go | 93 +++++++++- 7 files changed, 368 insertions(+), 61 deletions(-) diff --git a/internal/proto/authd/authd.pb.go b/internal/proto/authd/authd.pb.go index 89232fe5e4..0c66609a66 100644 --- a/internal/proto/authd/authd.pb.go +++ b/internal/proto/authd/authd.pb.go @@ -1317,6 +1317,50 @@ func (x *SetGroupIDRequest) GetId() uint32 { return 0 } +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"` @@ -1331,7 +1375,7 @@ type User struct { func (x *User) Reset() { *x = User{} - mi := &file_authd_proto_msgTypes[25] + mi := &file_authd_proto_msgTypes[26] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1343,7 +1387,7 @@ func (x *User) String() string { func (*User) ProtoMessage() {} func (x *User) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[25] + mi := &file_authd_proto_msgTypes[26] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1356,7 +1400,7 @@ func (x *User) ProtoReflect() protoreflect.Message { // Deprecated: Use User.ProtoReflect.Descriptor instead. func (*User) Descriptor() ([]byte, []int) { - return file_authd_proto_rawDescGZIP(), []int{25} + return file_authd_proto_rawDescGZIP(), []int{26} } func (x *User) GetName() string { @@ -1410,7 +1454,7 @@ type Users struct { func (x *Users) Reset() { *x = Users{} - mi := &file_authd_proto_msgTypes[26] + mi := &file_authd_proto_msgTypes[27] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1422,7 +1466,7 @@ func (x *Users) String() string { func (*Users) ProtoMessage() {} func (x *Users) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[26] + mi := &file_authd_proto_msgTypes[27] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1435,7 +1479,7 @@ func (x *Users) ProtoReflect() protoreflect.Message { // Deprecated: Use Users.ProtoReflect.Descriptor instead. func (*Users) Descriptor() ([]byte, []int) { - return file_authd_proto_rawDescGZIP(), []int{26} + return file_authd_proto_rawDescGZIP(), []int{27} } func (x *Users) GetUsers() []*User { @@ -1458,7 +1502,7 @@ type Group struct { func (x *Group) Reset() { *x = Group{} - mi := &file_authd_proto_msgTypes[27] + mi := &file_authd_proto_msgTypes[28] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1470,7 +1514,7 @@ func (x *Group) String() string { func (*Group) ProtoMessage() {} func (x *Group) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[27] + mi := &file_authd_proto_msgTypes[28] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1483,7 +1527,7 @@ func (x *Group) ProtoReflect() protoreflect.Message { // Deprecated: Use Group.ProtoReflect.Descriptor instead. func (*Group) Descriptor() ([]byte, []int) { - return file_authd_proto_rawDescGZIP(), []int{27} + return file_authd_proto_rawDescGZIP(), []int{28} } func (x *Group) GetName() string { @@ -1523,7 +1567,7 @@ type Groups struct { func (x *Groups) Reset() { *x = Groups{} - mi := &file_authd_proto_msgTypes[28] + mi := &file_authd_proto_msgTypes[29] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1535,7 +1579,7 @@ func (x *Groups) String() string { func (*Groups) ProtoMessage() {} func (x *Groups) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[28] + mi := &file_authd_proto_msgTypes[29] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1548,7 +1592,7 @@ func (x *Groups) ProtoReflect() protoreflect.Message { // Deprecated: Use Groups.ProtoReflect.Descriptor instead. func (*Groups) Descriptor() ([]byte, []int) { - return file_authd_proto_rawDescGZIP(), []int{28} + return file_authd_proto_rawDescGZIP(), []int{29} } func (x *Groups) GetGroups() []*Group { @@ -1569,7 +1613,7 @@ type ABResponse_BrokerInfo struct { func (x *ABResponse_BrokerInfo) Reset() { *x = ABResponse_BrokerInfo{} - mi := &file_authd_proto_msgTypes[29] + mi := &file_authd_proto_msgTypes[30] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1581,7 +1625,7 @@ func (x *ABResponse_BrokerInfo) String() string { func (*ABResponse_BrokerInfo) ProtoMessage() {} func (x *ABResponse_BrokerInfo) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[29] + mi := &file_authd_proto_msgTypes[30] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1628,7 +1672,7 @@ type GAMResponse_AuthenticationMode struct { func (x *GAMResponse_AuthenticationMode) Reset() { *x = GAMResponse_AuthenticationMode{} - mi := &file_authd_proto_msgTypes[30] + mi := &file_authd_proto_msgTypes[31] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1640,7 +1684,7 @@ func (x *GAMResponse_AuthenticationMode) String() string { func (*GAMResponse_AuthenticationMode) ProtoMessage() {} func (x *GAMResponse_AuthenticationMode) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[30] + mi := &file_authd_proto_msgTypes[31] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1685,7 +1729,7 @@ type IARequest_AuthenticationData struct { func (x *IARequest_AuthenticationData) Reset() { *x = IARequest_AuthenticationData{} - mi := &file_authd_proto_msgTypes[31] + mi := &file_authd_proto_msgTypes[32] ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) ms.StoreMessageInfo(mi) } @@ -1697,7 +1741,7 @@ func (x *IARequest_AuthenticationData) String() string { func (*IARequest_AuthenticationData) ProtoMessage() {} func (x *IARequest_AuthenticationData) ProtoReflect() protoreflect.Message { - mi := &file_authd_proto_msgTypes[31] + mi := &file_authd_proto_msgTypes[32] if x != nil { ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) if ms.LoadMessageInfo() == nil { @@ -1892,7 +1936,9 @@ const file_authd_proto_rawDesc = "" + "\bwarnings\x18\x01 \x03(\tR\bwarnings\"7\n" + "\x11SetGroupIDRequest\x12\x12\n" + "\x04name\x18\x01 \x01(\tR\x04name\x12\x0e\n" + - "\x02id\x18\x02 \x01(\rR\x02id\"\x84\x01\n" + + "\x02id\x18\x02 \x01(\rR\x02id\"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" + @@ -1922,7 +1968,7 @@ 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\xf3\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" + @@ -1930,7 +1976,9 @@ const file_authd_proto_rawDesc = "" + "\bLockUser\x12\x16.authd.LockUserRequest\x1a\f.authd.Empty\x124\n" + "\n" + "UnlockUser\x12\x18.authd.UnlockUserRequest\x1a\f.authd.Empty\x12>\n" + - "\tSetUserID\x12\x17.authd.SetUserIDRequest\x1a\x18.authd.SetUserIDResponse\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" + @@ -1949,7 +1997,7 @@ func file_authd_proto_rawDescGZIP() []byte { } var file_authd_proto_enumTypes = make([]protoimpl.EnumInfo, 1) -var file_authd_proto_msgTypes = make([]protoimpl.MessageInfo, 32) +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 @@ -1977,23 +2025,24 @@ var file_authd_proto_goTypes = []any{ (*SetUserIDRequest)(nil), // 23: authd.SetUserIDRequest (*SetUserIDResponse)(nil), // 24: authd.SetUserIDResponse (*SetGroupIDRequest)(nil), // 25: authd.SetGroupIDRequest - (*User)(nil), // 26: authd.User - (*Users)(nil), // 27: authd.Users - (*Group)(nil), // 28: authd.Group - (*Groups)(nil), // 29: authd.Groups - (*ABResponse_BrokerInfo)(nil), // 30: authd.ABResponse.BrokerInfo - (*GAMResponse_AuthenticationMode)(nil), // 31: authd.GAMResponse.AuthenticationMode - (*IARequest_AuthenticationData)(nil), // 32: authd.IARequest.AuthenticationData + (*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{ - 30, // 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 - 31, // 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 - 32, // 5: authd.IARequest.authentication_data:type_name -> authd.IARequest.AuthenticationData - 26, // 6: authd.Users.users:type_name -> authd.User - 28, // 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 @@ -2008,28 +2057,30 @@ var file_authd_proto_depIdxs = []int32{ 19, // 19: authd.UserService.LockUser:input_type -> authd.LockUserRequest 20, // 20: authd.UserService.UnlockUser:input_type -> authd.UnlockUserRequest 23, // 21: authd.UserService.SetUserID:input_type -> authd.SetUserIDRequest - 21, // 22: authd.UserService.GetGroupByName:input_type -> authd.GetGroupByNameRequest - 22, // 23: authd.UserService.GetGroupByID:input_type -> authd.GetGroupByIDRequest - 1, // 24: authd.UserService.ListGroups:input_type -> authd.Empty - 4, // 25: authd.PAM.AvailableBrokers:output_type -> authd.ABResponse - 3, // 26: authd.PAM.GetPreviousBroker:output_type -> authd.GPBResponse - 7, // 27: authd.PAM.SelectBroker:output_type -> authd.SBResponse - 10, // 28: authd.PAM.GetAuthenticationModes:output_type -> authd.GAMResponse - 12, // 29: authd.PAM.SelectAuthenticationMode:output_type -> authd.SAMResponse - 14, // 30: authd.PAM.IsAuthenticated:output_type -> authd.IAResponse - 1, // 31: authd.PAM.EndSession:output_type -> authd.Empty - 1, // 32: authd.PAM.SetDefaultBrokerForUser:output_type -> authd.Empty - 26, // 33: authd.UserService.GetUserByName:output_type -> authd.User - 26, // 34: authd.UserService.GetUserByID:output_type -> authd.User - 27, // 35: authd.UserService.ListUsers:output_type -> authd.Users - 1, // 36: authd.UserService.LockUser:output_type -> authd.Empty - 1, // 37: authd.UserService.UnlockUser:output_type -> authd.Empty - 24, // 38: authd.UserService.SetUserID:output_type -> authd.SetUserIDResponse - 28, // 39: authd.UserService.GetGroupByName:output_type -> authd.Group - 28, // 40: authd.UserService.GetGroupByID:output_type -> authd.Group - 29, // 41: authd.UserService.ListGroups:output_type -> authd.Groups - 25, // [25:42] is the sub-list for method output_type - 8, // [8:25] is the sub-list for method input_type + 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 @@ -2041,8 +2092,8 @@ func file_authd_proto_init() { return } file_authd_proto_msgTypes[8].OneofWrappers = []any{} - file_authd_proto_msgTypes[29].OneofWrappers = []any{} - file_authd_proto_msgTypes[31].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), @@ -2054,7 +2105,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: 32, + NumMessages: 33, NumExtensions: 0, NumServices: 2, }, diff --git a/internal/proto/authd/authd.proto b/internal/proto/authd/authd.proto index e0e4f52c0f..02bcbd105f 100644 --- a/internal/proto/authd/authd.proto +++ b/internal/proto/authd/authd.proto @@ -136,6 +136,7 @@ service UserService { 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); @@ -181,6 +182,10 @@ message SetGroupIDRequest { uint32 id = 2; } +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 0722db3ed2..8685f6fdca 100644 --- a/internal/proto/authd/authd_grpc.pb.go +++ b/internal/proto/authd/authd_grpc.pb.go @@ -393,6 +393,7 @@ const ( 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" @@ -408,6 +409,7 @@ type UserServiceClient interface { 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) @@ -481,6 +483,16 @@ func (c *userServiceClient) SetUserID(ctx context.Context, in *SetUserIDRequest, 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) @@ -521,6 +533,7 @@ type UserServiceServer interface { 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) @@ -550,7 +563,10 @@ func (UnimplementedUserServiceServer) UnlockUser(context.Context, *UnlockUserReq return nil, status.Error(codes.Unimplemented, "method UnlockUser not implemented") } func (UnimplementedUserServiceServer) SetUserID(context.Context, *SetUserIDRequest) (*SetUserIDResponse, error) { - return nil, status.Errorf(codes.Unimplemented, "method SetUserID not implemented") + 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") @@ -690,6 +706,24 @@ func _UserService_SetUserID_Handler(srv interface{}, ctx context.Context, dec fu 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 { @@ -775,6 +809,10 @@ var UserService_ServiceDesc = grpc.ServiceDesc{ MethodName: "SetUserID", Handler: _UserService_SetUserID_Handler, }, + { + MethodName: "SetGroupID", + Handler: _UserService_SetGroupID_Handler, + }, { MethodName: "GetGroupByName", Handler: _UserService_GetGroupByName_Handler, diff --git a/internal/services/testdata/golden/TestRegisterGRPCServices b/internal/services/testdata/golden/TestRegisterGRPCServices index b4e4c3df84..871d7f2387 100644 --- a/internal/services/testdata/golden/TestRegisterGRPCServices +++ b/internal/services/testdata/golden/TestRegisterGRPCServices @@ -48,6 +48,9 @@ authd.UserService: - name: LockUser isclientstream: false isserverstream: false + - name: SetGroupID + isclientstream: false + isserverstream: false - name: SetUserID isclientstream: false isserverstream: false diff --git a/internal/services/user/user.go b/internal/services/user/user.go index 785aaedf68..10ba777cf2 100644 --- a/internal/services/user/user.go +++ b/internal/services/user/user.go @@ -240,6 +240,28 @@ func (s Service) SetUserID(ctx context.Context, req *authd.SetUserIDRequest) (*a 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/users/db/update.go b/internal/users/db/update.go index 242c6ab7db..4b8c01c776 100644 --- a/internal/users/db/update.go +++ b/internal/users/db/update.go @@ -261,3 +261,100 @@ func (m *Manager) SetUserID(username string, newUID uint32) error { 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/manager.go b/internal/users/manager.go index 0b04f07801..536deb2e97 100644 --- a/internal/users/manager.go +++ b/internal/users/manager.go @@ -387,11 +387,12 @@ func (m *Manager) SetUserID(name string, uid uint32) (warnings []string, err err } defer func() { err = errors.Join(err, userslocking.WriteUnlock()) }() - // Check if the user already has the given UID + // 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) @@ -434,6 +435,7 @@ func (m *Manager) SetUserID(name string, uid uint32) (warnings []string, err err } // 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 @@ -442,6 +444,95 @@ func (m *Manager) SetUserID(name string, uid uint32) (warnings []string, err 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) + } + + // 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 { From 22503b8337edc20747a45dd74f1437ba869223cb Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 25 Nov 2025 22:34:02 +0100 Subject: [PATCH 22/49] Add db_test.TestSetGroupID --- internal/users/db/db_test.go | 77 +++++++++++++++++++ .../Set_group_id_for_existing_group | 64 +++++++++++++++ 2 files changed, 141 insertions(+) create mode 100644 internal/users/db/testdata/golden/TestSetGroupID/Set_group_id_for_existing_group diff --git a/internal/users/db/db_test.go b/internal/users/db/db_test.go index 22e856bcfd..47cce2060f 100644 --- a/internal/users/db/db_test.go +++ b/internal/users/db/db_test.go @@ -936,6 +936,83 @@ func TestSetUserID(t *testing.T) { } } +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 From d45de53509f4ef1b56d7cd780083410798798482 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 2 Dec 2025 15:00:37 +0100 Subject: [PATCH 23/49] Add users_test.TestSetGroupID --- internal/users/manager_bwrap_test.go | 164 ++++++++++++++++++ .../db | 56 ++++++ .../TestSetGroupID/Successfully_set_GID/db | 60 +++++++ .../db | 60 +++++++ .../db | 60 +++++++ .../db | 60 +++++++ .../db | 64 +++++++ .../Warning_if_group_already_has_given_GID/db | 60 +++++++ .../warnings | 1 + .../db | 60 +++++++ .../warnings | 1 + .../db | 60 +++++++ .../warnings | 1 + 13 files changed, 707 insertions(+) create mode 100644 internal/users/testdata/golden/TestSetGroupID/Primary_groups_of_multiple_users_are_updated/db create mode 100644 internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID/db create mode 100644 internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID_if_ID_is_already_in_use_as_UID_of_authd_user/db create mode 100644 internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID_if_ID_is_already_in_use_as_UID_of_system_user/db create mode 100644 internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID_when_group_is_not_primary_group_of_any_user/db create mode 100644 internal/users/testdata/golden/TestSetGroupID/Successfully_set_GID_when_home_directory_does_not_exist/db create mode 100644 internal/users/testdata/golden/TestSetGroupID/Warning_if_group_already_has_given_GID/db create mode 100644 internal/users/testdata/golden/TestSetGroupID/Warning_if_group_already_has_given_GID/warnings create mode 100644 internal/users/testdata/golden/TestSetGroupID/Warning_if_home_directory_cannot_be_accessed/db create mode 100644 internal/users/testdata/golden/TestSetGroupID/Warning_if_home_directory_cannot_be_accessed/warnings create mode 100644 internal/users/testdata/golden/TestSetGroupID/Warning_if_home_directory_is_owned_by_other_group/db create mode 100644 internal/users/testdata/golden/TestSetGroupID/Warning_if_home_directory_is_owned_by_other_group/warnings diff --git a/internal/users/manager_bwrap_test.go b/internal/users/manager_bwrap_test.go index 3dc32fb466..fa3475cb64 100644 --- a/internal/users/manager_bwrap_test.go +++ b/internal/users/manager_bwrap_test.go @@ -147,6 +147,145 @@ func TestSetUserID(t *testing.T) { } } +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() @@ -227,6 +366,30 @@ func setUID(t *testing.T, m *users.Manager, username string, uid int) { 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() @@ -234,6 +397,7 @@ func addUserToSystem(t *testing.T, uid int) { cmd := exec.Command( "useradd", "--uid", strconv.Itoa(uid), + "--gid", "0", "--no-create-home", fmt.Sprintf("test-%d", uid), ) 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}})' From bb5718ded749001d16a109af946c941741b64488 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 2 Dec 2025 15:28:05 +0100 Subject: [PATCH 24/49] Add `authctl group` command --- cmd/authctl/group/group.go | 14 ++++ cmd/authctl/group/group_test.go | 76 +++++++++++++++++++ .../TestGroupCommand/Error_on_invalid_command | 13 ++++ .../TestGroupCommand/Error_on_invalid_flag | 13 ++++ .../golden/TestGroupCommand/Help_flag | 13 ++++ .../Usage_message_when_no_args | 11 +++ cmd/authctl/main.go | 2 + 7 files changed, 142 insertions(+) create mode 100644 cmd/authctl/group/group.go create mode 100644 cmd/authctl/group/group_test.go create mode 100644 cmd/authctl/group/testdata/golden/TestGroupCommand/Error_on_invalid_command create mode 100644 cmd/authctl/group/testdata/golden/TestGroupCommand/Error_on_invalid_flag create mode 100644 cmd/authctl/group/testdata/golden/TestGroupCommand/Help_flag create mode 100644 cmd/authctl/group/testdata/golden/TestGroupCommand/Usage_message_when_no_args diff --git a/cmd/authctl/group/group.go b/cmd/authctl/group/group.go new file mode 100644 index 0000000000..3ded99cf5b --- /dev/null +++ b/cmd/authctl/group/group.go @@ -0,0 +1,14 @@ +// 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() }, +} diff --git a/cmd/authctl/group/group_test.go b/cmd/authctl/group/group_test.go new file mode 100644 index 0000000000..f77df9c1b3 --- /dev/null +++ b/cmd/authctl/group/group_test.go @@ -0,0 +1,76 @@ +package group_test + +import ( + "fmt" + "os" + "os/exec" + "strings" + "testing" + + "github.com/canonical/authd/internal/testutils" + "github.com/canonical/authd/internal/testutils/golden" +) + +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...)...) + 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 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/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/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() { From 6bf162feb68d30d9c984a152abb7b111849de0fc Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 2 Dec 2025 15:19:36 +0100 Subject: [PATCH 25/49] Add `authctl group set-gid` command --- cmd/authctl/group/group.go | 4 ++ cmd/authctl/group/set-gid.go | 53 +++++++++++++++++++ .../TestRootCommand/Error_on_invalid_command | 1 + .../TestRootCommand/Error_on_invalid_flag | 1 + .../golden/TestRootCommand/Help_command | 1 + .../testdata/golden/TestRootCommand/Help_flag | 1 + .../Usage_message_when_no_args | 1 + 7 files changed, 62 insertions(+) create mode 100644 cmd/authctl/group/set-gid.go diff --git a/cmd/authctl/group/group.go b/cmd/authctl/group/group.go index 3ded99cf5b..01a7c2b88b 100644 --- a/cmd/authctl/group/group.go +++ b/cmd/authctl/group/group.go @@ -12,3 +12,7 @@ var GroupCmd = &cobra.Command{ 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/set-gid.go b/cmd/authctl/group/set-gid.go new file mode 100644 index 0000000000..15a3e72559 --- /dev/null +++ b/cmd/authctl/group/set-gid.go @@ -0,0 +1,53 @@ +package group + +import ( + "context" + "errors" + "fmt" + "os" + "strconv" + + "github.com/canonical/authd/cmd/authctl/user" + "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", + Args: cobra.ExactArgs(2), + 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 := user.NewUserServiceClient() + if err != nil { + return err + } + + resp, err := client.SetGroupID(context.Background(), &authd.SetGroupIDRequest{ + Name: name, + Id: uint32(gid), + }) + 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/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: From bf0272fc6bf5074805d153651b034e8d7f858289 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 2 Dec 2025 15:20:04 +0100 Subject: [PATCH 26/49] Add TestSetGIDCommand --- cmd/authctl/group/set-gid_test.go | 101 ++++++++++++++++++ .../testdata/db/one_user_and_group.db.yaml | 17 +++ cmd/authctl/group/testdata/empty.group | 0 .../Error_when_authd_is_unavailable | 1 + .../Error_when_gid_is_already_taken | 1 + .../Error_when_gid_is_invalid | 1 + .../Error_when_gid_is_negative | 7 ++ .../Error_when_gid_is_too_large | 1 + .../Error_when_group_does_not_exist | 1 + .../TestSetGIDCommand/Set_group_gid_success | 0 10 files changed, 130 insertions(+) create mode 100644 cmd/authctl/group/set-gid_test.go create mode 100644 cmd/authctl/group/testdata/db/one_user_and_group.db.yaml create mode 100644 cmd/authctl/group/testdata/empty.group create mode 100644 cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_authd_is_unavailable create mode 100644 cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_already_taken create mode 100644 cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_invalid create mode 100644 cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_negative create mode 100644 cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_gid_is_too_large create mode 100644 cmd/authctl/group/testdata/golden/TestSetGIDCommand/Error_when_group_does_not_exist create mode 100644 cmd/authctl/group/testdata/golden/TestSetGIDCommand/Set_group_gid_success diff --git a/cmd/authctl/group/set-gid_test.go b/cmd/authctl/group/set-gid_test.go new file mode 100644 index 0000000000..38a2049ca4 --- /dev/null +++ b/cmd/authctl/group/set-gid_test.go @@ -0,0 +1,101 @@ +package group_test + +import ( + "math" + "os" + "os/exec" + "path/filepath" + "strconv" + "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" +) + +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...)...) + 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) + }) + } +} 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/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 From ea2ac9c2238cd3cb9cc4cc7e1cf555935440e66d Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 2 Dec 2025 17:36:55 +0100 Subject: [PATCH 27/49] pam/integration-tests: Don't print authctl usage message in VHS tape It doesn't test anything that's not already covered by other tests and it's annoying to have to manually update the golden files of the SSH integration tests whenever the authctl usage message changes. --- .../Authenticate_user_locks_and_unlocks_it | 25 ------------------- ...user_locks_and_unlocks_it_with_shared_sshd | 25 ------------------- .../tapes/ssh/simple_auth_locks_unlocks.tape | 16 ------------ 3 files changed, 66 deletions(-) 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 From 167c881457ee69cd3b1de642729f1afff86b1e34 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 2 Dec 2025 18:31:14 +0100 Subject: [PATCH 28/49] authctl: Add long description for set-uid command --- cmd/authctl/user/set-uid.go | 22 +++++++++++++++++++++- 1 file changed, 21 insertions(+), 1 deletion(-) diff --git a/cmd/authctl/user/set-uid.go b/cmd/authctl/user/set-uid.go index 903de257c9..df6dc7c796 100644 --- a/cmd/authctl/user/set-uid.go +++ b/cmd/authctl/user/set-uid.go @@ -15,7 +15,27 @@ import ( var setUIDCmd = &cobra.Command{ Use: "set-uid ", Short: "Set the UID of a user managed by authd", - Args: cobra.ExactArgs(2), + 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), RunE: func(cmd *cobra.Command, args []string) error { name := args[0] uidStr := args[1] From 78b62e9f67238232be10545dc13fd0439668b708 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Wed, 3 Dec 2025 14:40:50 +0100 Subject: [PATCH 29/49] authctl: Add long description for set-gid command --- cmd/authctl/group/set-gid.go | 23 ++++++++++++++++++++++- 1 file changed, 22 insertions(+), 1 deletion(-) diff --git a/cmd/authctl/group/set-gid.go b/cmd/authctl/group/set-gid.go index 15a3e72559..54236a15eb 100644 --- a/cmd/authctl/group/set-gid.go +++ b/cmd/authctl/group/set-gid.go @@ -16,7 +16,28 @@ import ( var setGIDCmd = &cobra.Command{ Use: "set-gid ", Short: "Set the GID of a group managed by authd", - Args: cobra.ExactArgs(2), + 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), RunE: func(cmd *cobra.Command, args []string) error { name := args[0] gidStr := args[1] From 5db300aaf8f5d7e173c81490d7ffd061acf66425 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Thu, 4 Dec 2025 01:21:37 +0100 Subject: [PATCH 30/49] Take lock in SetUserID/SetGroupID userslocking.WriteLock() immediately returns ErrLock if the lock is already taken *by the current process*. lckpwdf behaves similarly (even though the man page doesn't mention it). To avoid that issue, we now take another lock which blocks concurrent goroutines. --- internal/users/manager.go | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/internal/users/manager.go b/internal/users/manager.go index 536deb2e97..e6088db43e 100644 --- a/internal/users/manager.go +++ b/internal/users/manager.go @@ -380,6 +380,9 @@ func (m *Manager) SetUserID(name string, uid uint32) (warnings []string, err err 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 { @@ -456,6 +459,9 @@ func (m *Manager) SetGroupID(name string, gid uint32) (warnings []string, err er 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 { From 73b39b9032b2935ad921f5ec1cab3e2d2907a2b5 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Thu, 4 Dec 2025 12:57:04 +0100 Subject: [PATCH 31/49] tests: Don't skip bubblewrap tests in CI We broke the bubblewrap tests in the CI without noticing it (at first) because the tests were skipped. The only case where we really want to skip the tests is on Launchpad builders. To detect that, we check if the DEB_BUILD_ARCH environment variable is set and we're *not* in GitHub CI. --- internal/testutils/bubblewrap.go | 23 ++++++++++---------- internal/users/locking/locking_bwrap_test.go | 3 --- 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/internal/testutils/bubblewrap.go b/internal/testutils/bubblewrap.go index 9b376f697e..12be8d5e7c 100644 --- a/internal/testutils/bubblewrap.go +++ b/internal/testutils/bubblewrap.go @@ -31,38 +31,39 @@ 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 = 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) + if !canRunBubblewrap(t) { + if IsDebianPackageBuild() && !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") + } testCommand := []string{os.Args[0], "-test.run", "^" + t.Name() + "$"} if testing.Verbose() { diff --git a/internal/users/locking/locking_bwrap_test.go b/internal/users/locking/locking_bwrap_test.go index 89fa775e45..c8807ef67b 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,8 +475,6 @@ func compileLockerBinary(t *testing.T, tempDir string) { func testInBubbleWrapWithLockerBinary(t *testing.T) { t.Helper() - testutils.SkipIfCannotRunBubbleWrap(t) - compileLockerBinaryOnce.Do(func() { compileLockerBinary(t, tempDir) }) From a2bcab21ba4c5cebbed05c972c0ebd691378d4ac Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Thu, 4 Dec 2025 15:03:40 +0100 Subject: [PATCH 32/49] Try enabling unprivileged user namespaces in CI --- internal/testutils/bubblewrap.go | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/internal/testutils/bubblewrap.go b/internal/testutils/bubblewrap.go index 12be8d5e7c..36207030d8 100644 --- a/internal/testutils/bubblewrap.go +++ b/internal/testutils/bubblewrap.go @@ -171,6 +171,20 @@ func canUseUnprivilegedUserNamespaces(t *testing.T) bool { 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() + } + if err := runInBubbleWrap(t, false, nil, "/bin/true"); err != nil { t.Logf("Can't use user namespaces: %v", err) return false From 545661a5680566cb28f50fcf257c5c5146bf2dda Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Thu, 4 Dec 2025 03:12:34 +0100 Subject: [PATCH 33/49] tests: Avoid unshare hanging forever in some environments When executing `unshare --map-user` via exec.Command and connecting the process's stdout or stderr, the command hangs forever if unprivileged user namespaces are disabled. We avoid that by checking via `unshare --user` if unprivileged user namespaces are enabled. --- internal/testutils/bubblewrap.go | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/internal/testutils/bubblewrap.go b/internal/testutils/bubblewrap.go index 36207030d8..59f150c7a4 100644 --- a/internal/testutils/bubblewrap.go +++ b/internal/testutils/bubblewrap.go @@ -185,8 +185,17 @@ func canUseUnprivilegedUserNamespaces(t *testing.T) bool { _ = cmd.Run() } + cmd := exec.Command("unshare", "--map-root-user", "/bin/true") + cmd.Stdout = t.Output() + cmd.Stderr = t.Output() + t.Log("Running command:", cmd.String()) + if err := cmd.Run(); err != nil { + t.Logf("Can't use unprivileged user namespaces: %v", err) + return false + } + if err := runInBubbleWrap(t, false, nil, "/bin/true"); err != nil { - t.Logf("Can't use user namespaces: %v", err) + t.Logf("Can't use user unprivileged user namespaces with bwrap: %v", err) return false } From 049b7dc6a6845e37b44a6e98710edcfdfe40e3b0 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Thu, 4 Dec 2025 14:38:13 +0100 Subject: [PATCH 34/49] tests: Make chown work with bubblewrap executed via sudo --- internal/testutils/bubblewrap.go | 34 +++++++++++++++++++++----------- 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/internal/testutils/bubblewrap.go b/internal/testutils/bubblewrap.go index 59f150c7a4..73986a7a88 100644 --- a/internal/testutils/bubblewrap.go +++ b/internal/testutils/bubblewrap.go @@ -96,20 +96,30 @@ func runInBubbleWrap(t *testing.T, withSudo bool, env []string, args ...string) require.NoError(t, err, "Setup: could not copy bubblewrap binary to temp location") }) - // 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-auto", - copiedBwrapPath) - - cmd.Env = AppendCovEnv(os.Environ()) - cmd.Env = append(cmd.Env, env...) - cmd.Env = append(cmd.Env, bubbleWrapTestEnvVar+"=1") + env = AppendCovEnv(env) + env = append(env, bubbleWrapTestEnvVar+"=1") + + var cmd *exec.Cmd if withSudo { - cmd.Args = append([]string{"sudo"}, cmd.Args...) + 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 = append(os.Environ(), env...) } etcDir := filepath.Join(TempDir(t), "etc") From f6f84b9779d8360a6e9f94e48cac8010cfb629d8 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Mon, 8 Dec 2025 19:40:11 +0100 Subject: [PATCH 35/49] tests: Skip bubblewrap tests in autopkgtest The "Run autopkgtests" CI job runs the tests in an LXD container which doesn't allow using bubblewrap. It fails with: bwrap: Failed to make / slave: Permission denied To avoid that these jobs fail, we allow them to skip the bubblewrap tests. We still run the tests in the "Go Tests" CI jobs. --- internal/testutils/args.go | 6 ++++++ internal/testutils/bubblewrap.go | 2 +- 2 files changed, 7 insertions(+), 1 deletion(-) 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/bubblewrap.go b/internal/testutils/bubblewrap.go index 73986a7a88..b5d19c5b59 100644 --- a/internal/testutils/bubblewrap.go +++ b/internal/testutils/bubblewrap.go @@ -57,7 +57,7 @@ func RunTestInBubbleWrap(t *testing.T, args ...string) { t.Helper() if !canRunBubblewrap(t) { - if IsDebianPackageBuild() && !IsCI() { + if (IsDebianPackageBuild() || 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") From d5511f51ca3012c7b60d09140b25d26ae28ff54f Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Wed, 10 Dec 2025 09:47:05 +0100 Subject: [PATCH 36/49] ci: Group log lines of llvm-symbolizer installation --- .github/workflows/qa.yaml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/qa.yaml b/.github/workflows/qa.yaml index 9ec6289d1a..17b7821901 100644 --- a/.github/workflows/qa.yaml +++ b/.github/workflows/qa.yaml @@ -333,8 +333,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=$? From 225b205ee5c4ce9130f502ba57fc9f100db497fa Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Wed, 10 Dec 2025 09:47:47 +0100 Subject: [PATCH 37/49] autopkgtest: Run go tests without -v Running our tests with -v produces so much output that it makes it harder to inspect test failures, for example when viewing the logs of the "Run autopkgtests" CI job in GitHub. Running the tests without -v still prints the logs of the failed tests which should include all the information we need to debug test failures. --- debian/tests/run-tests.sh | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 ./... From a1a514f33ae166b6e991d10ad5b7ed0808282ec3 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Thu, 11 Dec 2025 22:50:27 +0100 Subject: [PATCH 38/49] Check if user is busy before changing its UID --- debian/authd.service.in | 4 +- internal/users/manager.go | 7 ++ internal/users/proc/proc.go | 209 +++++++++++++++++++++++++++++++ internal/users/proc/proc_test.go | 49 ++++++++ 4 files changed, 268 insertions(+), 1 deletion(-) create mode 100644 internal/users/proc/proc.go create mode 100644 internal/users/proc/proc_test.go diff --git a/debian/authd.service.in b/debian/authd.service.in index 299faa8e7c..b3729ad741 100644 --- a/debian/authd.service.in +++ b/debian/authd.service.in @@ -91,4 +91,6 @@ ProcSubset=pid # 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. -CapabilityBoundingSet=CAP_CHOWN CAP_DAC_READ_SEARCH +# 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/internal/users/manager.go b/internal/users/manager.go index e6088db43e..dd137c7515 100644 --- a/internal/users/manager.go +++ b/internal/users/manager.go @@ -18,6 +18,7 @@ import ( "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" @@ -413,6 +414,12 @@ func (m *Manager) SetUserID(name string, uid uint32) (warnings []string, err err 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 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") + }) + } +} From 33b3e20a3efc81d21c231bf2d50b10b84a9c315a Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Wed, 10 Dec 2025 10:44:29 +0100 Subject: [PATCH 39/49] authctl: Add argument completion --- cmd/authctl/group/set-gid.go | 8 +- cmd/authctl/internal/client/client.go | 35 +++++++++ cmd/authctl/internal/completion/completion.go | 77 +++++++++++++++++++ cmd/authctl/user/lock.go | 11 ++- cmd/authctl/user/set-uid.go | 15 +++- cmd/authctl/user/unlock.go | 11 ++- cmd/authctl/user/user.go | 30 -------- 7 files changed, 144 insertions(+), 43 deletions(-) create mode 100644 cmd/authctl/internal/client/client.go create mode 100644 cmd/authctl/internal/completion/completion.go diff --git a/cmd/authctl/group/set-gid.go b/cmd/authctl/group/set-gid.go index 54236a15eb..8427d20c78 100644 --- a/cmd/authctl/group/set-gid.go +++ b/cmd/authctl/group/set-gid.go @@ -7,7 +7,8 @@ import ( "os" "strconv" - "github.com/canonical/authd/cmd/authctl/user" + "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" ) @@ -37,7 +38,8 @@ This command requires root privileges. Examples: authctl group set-gid staff 30000 authctl group set-gid developers 40000`, - Args: cobra.ExactArgs(2), + Args: cobra.ExactArgs(2), + ValidArgsFunction: completion.Groups, RunE: func(cmd *cobra.Command, args []string) error { name := args[0] gidStr := args[1] @@ -51,7 +53,7 @@ Examples: return fmt.Errorf("failed to parse GID %q: %w", gidStr, err) } - client, err := user.NewUserServiceClient() + client, err := client.NewUserServiceClient() if err != nil { return err } 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/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/set-uid.go b/cmd/authctl/user/set-uid.go index df6dc7c796..1de3e10f40 100644 --- a/cmd/authctl/user/set-uid.go +++ b/cmd/authctl/user/set-uid.go @@ -7,6 +7,8 @@ import ( "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" ) @@ -35,7 +37,8 @@ This command requires root privileges. Examples: authctl user set-uid john 15000 authctl user set-uid alice 20000`, - Args: cobra.ExactArgs(2), + Args: cobra.ExactArgs(2), + ValidArgsFunction: setUIDCompletionFunc, RunE: func(cmd *cobra.Command, args []string) error { name := args[0] uidStr := args[1] @@ -49,7 +52,7 @@ Examples: return fmt.Errorf("failed to parse UID %q: %w", uidStr, err) } - client, err := NewUserServiceClient() + client, err := client.NewUserServiceClient() if err != nil { return err } @@ -70,3 +73,11 @@ Examples: 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/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 1552222408..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,28 +13,6 @@ 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) From 9b4fa0bc6429d608998937dd460d493f87288d82 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 16 Dec 2025 13:07:51 +0100 Subject: [PATCH 40/49] testlog: Fix duplicate newlines --- internal/testlog/testlog.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/testlog/testlog.go b/internal/testlog/testlog.go index 53b5babe0c..742b803661 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,21 @@ func LogEndSeparator(t *testing.T, args ...any) { } w := testOutput(t) - fmt.Fprintln(w, separator(args...)+"\n") + fmt.Fprint(w, separator(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. From 6b124f476ebe531f1f0bd4dc3422be888f12c778 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 16 Dec 2025 14:27:10 +0100 Subject: [PATCH 41/49] tests: Improve logging of bubblewrap commands --- internal/testlog/testlog.go | 24 +++ internal/testutils/bubblewrap.go | 177 +++++++++++-------- internal/testutils/root.go | 35 ++-- internal/users/locking/locking_bwrap_test.go | 2 + 4 files changed, 149 insertions(+), 89 deletions(-) diff --git a/internal/testlog/testlog.go b/internal/testlog/testlog.go index 742b803661..d075cd529e 100644 --- a/internal/testlog/testlog.go +++ b/internal/testlog/testlog.go @@ -149,6 +149,30 @@ func LogEndSeparator(t *testing.T, args ...any) { 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...) + " =====") diff --git a/internal/testutils/bubblewrap.go b/internal/testutils/bubblewrap.go index b5d19c5b59..317e706c43 100644 --- a/internal/testutils/bubblewrap.go +++ b/internal/testutils/bubblewrap.go @@ -9,6 +9,7 @@ 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" ) @@ -56,15 +57,60 @@ func canRunBubblewrap(t *testing.T) bool { func RunTestInBubbleWrap(t *testing.T, args ...string) { t.Helper() - if !canRunBubblewrap(t) { - if (IsDebianPackageBuild() || 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") + RequireBubblewrap(t) + + etcDir := filepath.Join(TempDir(t), "etc") + err := os.MkdirAll(etcDir, 0700) + require.NoError(t, err, "Setup: could not create etc dir") + + // 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) } + env := []string{ + "PATH=" + MinimalPathEnv, + bubbleWrapTestEnvVar + "=1", + } + env = AppendCovEnv(env) + + cmd := bubbleWrapCommand(t, env, bubbleWrapNeedsSudo) + + cmd.Args = append(cmd.Args, + "--bind", etcDir, "/etc", + + // Bind relevant etc files. We go manual here, since there's no + // need to get much more than those, while we could in theory just + // bind everything that is in host, and excluding the ones we want + // to override. + "--ro-bind", "/etc/environment", "/etc/environment", + "--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/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], + ) + + if coverDir := CoverDirForTests(); coverDir != "" { + cmd.Args = append(cmd.Args, "--bind", coverDir, coverDir) + } + + 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...) + testCommand := []string{os.Args[0], "-test.run", "^" + t.Name() + "$"} if testing.Verbose() { testCommand = append(testCommand, "-test.v") @@ -72,18 +118,45 @@ func RunTestInBubbleWrap(t *testing.T, args ...string) { if c := CoverDirForTests(); c != "" { testCommand = append(testCommand, fmt.Sprintf("-test.gocoverdir=%s", c)) } - args = append(args, testCommand...) + cmd.Args = append(cmd.Args, testCommand...) - t.Logf("Running %s in bubblewrap", t.Name()) - err := runInBubbleWrap(t, bubbleWrapNeedsSudo, nil, args...) + 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") + } } -func runInBubbleWrap(t *testing.T, withSudo bool, env []string, args ...string) error { +// 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 @@ -96,11 +169,6 @@ func runInBubbleWrap(t *testing.T, withSudo bool, env []string, args ...string) require.NoError(t, err, "Setup: could not copy bubblewrap binary to temp location") }) - env = AppendCovEnv(env) - env = append(env, bubbleWrapTestEnvVar+"=1") - - var cmd *exec.Cmd - if withSudo { t.Log("Running bubblewrap with sudo") cmd = exec.Command("sudo", env...) @@ -119,68 +187,24 @@ func runInBubbleWrap(t *testing.T, withSudo bool, env []string, args ...string) "--map-groups=auto", copiedBwrapPath, ) - cmd.Env = append(os.Environ(), env...) - } - - etcDir := filepath.Join(TempDir(t), "etc") - err := os.MkdirAll(etcDir, 0700) - require.NoError(t, err, "Setup: could not create etc dir") - - // 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) + cmd.Env = env } cmd.Args = append(cmd.Args, "--ro-bind", "/", "/", "--dev", "/dev", "--tmpfs", "/tmp", - "--bind", etcDir, "/etc", - - // Bind relevant etc files. We go manual here, since there's no - // need to get much more than those, while we could in theory just - // bind everything that is in host, and excluding the ones we want - // to override. - "--ro-bind", "/etc/environment", "/etc/environment", - "--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/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], ) - if coverDir := CoverDirForTests(); coverDir != "" { - cmd.Args = append(cmd.Args, "--bind", coverDir, coverDir) - } - - 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...) 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") @@ -195,21 +219,31 @@ func canUseUnprivilegedUserNamespaces(t *testing.T) bool { _ = 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() - t.Log("Running command:", cmd.String()) + testlog.LogCommand(t, "Checking unprivileged user namespaces", cmd) if err := cmd.Run(); err != nil { - t.Logf("Can't use unprivileged user namespaces: %v", err) + testlog.LogRedEndSeparator(t, "Cannot use unprivileged user namespaces") return false } + testlog.LogEndSeparator(t, "Can use unprivileged user namespaces") - if err := runInBubbleWrap(t, false, nil, "/bin/true"); err != nil { - t.Logf("Can't use user unprivileged user namespaces with bwrap: %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 } @@ -220,11 +254,14 @@ func canUseBwrapWithSudoNonInteractively(t *testing.T) bool { return false } - if err := runInBubbleWrap(t, true, 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/root.go b/internal/testutils/root.go index ce03ea658f..26cb85d459 100644 --- a/internal/testutils/root.go +++ b/internal/testutils/root.go @@ -7,6 +7,7 @@ import ( "sync" "testing" + "github.com/canonical/authd/internal/testlog" "github.com/canonical/authd/internal/testutils/golden" ) @@ -58,34 +59,30 @@ func RunTestAsRoot(t *testing.T, args ...string) { } args = append(args, testCommand...) - t.Logf("Running %s as root", t.Name()) - err := runSudoCommand(t, args...) + 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 { - t.Fatalf("Failed to run test %s as root: %v", t.Name(), err) + testlog.LogEndSeparator(t, fmt.Sprintf("%s as root failed", t.Name())) + t.Fatalf("Running %s as root failed: %v", t.Name(), err) } -} - -func runSudoCommand(t *testing.T, args ...string) error { - t.Helper() - - sudoArgs := append([]string{"-n"}, args...) - //nolint:gosec // G204 we want to use exec.Command with variables here - cmd := exec.Command("sudo", sudoArgs...) - cmd.Stdout = os.Stdout - cmd.Stderr = os.Stderr - t.Log("Running command:", cmd.String()) - return cmd.Run() + testlog.LogEndSeparator(t, fmt.Sprintf("%s as root finished", t.Name())) } func canUseSudoNonInteractively(t *testing.T) bool { t.Helper() - t.Log("Checking if we can use sudo non-interactively") - cmd := exec.Command("sudo", "-n", "true") - if out, err := cmd.CombinedOutput(); err != nil { - t.Logf("Can't use sudo non-interactively: %v\n%s", err, out) + 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/locking/locking_bwrap_test.go b/internal/users/locking/locking_bwrap_test.go index c8807ef67b..c31a69f6b1 100644 --- a/internal/users/locking/locking_bwrap_test.go +++ b/internal/users/locking/locking_bwrap_test.go @@ -475,6 +475,8 @@ func compileLockerBinary(t *testing.T, tempDir string) { func testInBubbleWrapWithLockerBinary(t *testing.T) { t.Helper() + testutils.RequireBubblewrap(t) + compileLockerBinaryOnce.Do(func() { compileLockerBinary(t, tempDir) }) From d17766f413afda3c20380ecb57aa838c64199891 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 16 Dec 2025 15:28:25 +0100 Subject: [PATCH 42/49] tests: Improve logging when building authd and authctl --- internal/testutils/authctl.go | 6 +++--- internal/testutils/daemon.go | 1 - 2 files changed, 3 insertions(+), 4 deletions(-) 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/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) From 0b455886cb5381397ee8098d0e1f503f7affce22 Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Tue, 16 Dec 2025 16:21:46 +0100 Subject: [PATCH 43/49] tests: Improve logging of authctl tests --- cmd/authctl/group/group_test.go | 19 +--------------- cmd/authctl/group/set-gid_test.go | 16 +------------- cmd/authctl/main_test.go | 18 +--------------- cmd/authctl/user/lock_test.go | 16 +------------- cmd/authctl/user/set-uid_test.go | 16 +------------- cmd/authctl/user/user_test.go | 19 +--------------- internal/testutils/exec.go | 36 +++++++++++++++++++++++++++++++ 7 files changed, 42 insertions(+), 98 deletions(-) create mode 100644 internal/testutils/exec.go diff --git a/cmd/authctl/group/group_test.go b/cmd/authctl/group/group_test.go index f77df9c1b3..34d3093521 100644 --- a/cmd/authctl/group/group_test.go +++ b/cmd/authctl/group/group_test.go @@ -4,11 +4,9 @@ import ( "fmt" "os" "os/exec" - "strings" "testing" "github.com/canonical/authd/internal/testutils" - "github.com/canonical/authd/internal/testutils/golden" ) var authctlPath string @@ -34,22 +32,7 @@ func TestGroupCommand(t *testing.T) { //nolint:gosec // G204 it's safe to use exec.Command with a variable here cmd := exec.Command(authctlPath, append([]string{"group"}, 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) + testutils.CheckCommand(t, cmd, tc.expectedExitCode) }) } } diff --git a/cmd/authctl/group/set-gid_test.go b/cmd/authctl/group/set-gid_test.go index 38a2049ca4..97d8456a74 100644 --- a/cmd/authctl/group/set-gid_test.go +++ b/cmd/authctl/group/set-gid_test.go @@ -6,11 +6,9 @@ import ( "os/exec" "path/filepath" "strconv" - "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" ) @@ -83,19 +81,7 @@ func TestSetGIDCommand(t *testing.T) { //nolint:gosec // G204 it's safe to use exec.Command with a variable here cmd := exec.Command(authctlPath, append([]string{"group"}, 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/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/user/lock_test.go b/cmd/authctl/user/lock_test.go index d7348984ed..eb89ac349a 100644 --- a/cmd/authctl/user/lock_test.go +++ b/cmd/authctl/user/lock_test.go @@ -4,11 +4,9 @@ import ( "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" ) @@ -40,19 +38,7 @@ func TestUserLockCommand(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() - - 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/cmd/authctl/user/set-uid_test.go b/cmd/authctl/user/set-uid_test.go index 5b025ae5a6..ffdcda0ad3 100644 --- a/cmd/authctl/user/set-uid_test.go +++ b/cmd/authctl/user/set-uid_test.go @@ -6,11 +6,9 @@ import ( "os/exec" "path/filepath" "strconv" - "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" ) @@ -83,19 +81,7 @@ func TestSetUIDCommand(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() - - 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/cmd/authctl/user/user_test.go b/cmd/authctl/user/user_test.go index 7951463545..5010e8cc74 100644 --- a/cmd/authctl/user/user_test.go +++ b/cmd/authctl/user/user_test.go @@ -4,11 +4,9 @@ import ( "fmt" "os" "os/exec" - "strings" "testing" "github.com/canonical/authd/internal/testutils" - "github.com/canonical/authd/internal/testutils/golden" ) var authctlPath string @@ -34,22 +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) + testutils.CheckCommand(t, cmd, tc.expectedExitCode) }) } } 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()) +} From ad0dd6dec9f22dbc5059522c96618e9e6e976e2b Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Fri, 19 Dec 2025 14:08:02 +0100 Subject: [PATCH 44/49] authctl: Tell authd which language to return warnings in As suggested by reviewer. It's not implemented for now, warnings are always returned in English. --- cmd/authctl/group/set-gid.go | 1 + cmd/authctl/user/set-uid.go | 1 + internal/proto/authd/authd.pb.go | 42 ++++++++++++++++++++++++-------- internal/proto/authd/authd.proto | 6 +++++ 4 files changed, 40 insertions(+), 10 deletions(-) diff --git a/cmd/authctl/group/set-gid.go b/cmd/authctl/group/set-gid.go index 8427d20c78..a70262b6de 100644 --- a/cmd/authctl/group/set-gid.go +++ b/cmd/authctl/group/set-gid.go @@ -61,6 +61,7 @@ Examples: resp, err := client.SetGroupID(context.Background(), &authd.SetGroupIDRequest{ Name: name, Id: uint32(gid), + Lang: os.Getenv("LANG"), }) if err != nil { return err diff --git a/cmd/authctl/user/set-uid.go b/cmd/authctl/user/set-uid.go index 1de3e10f40..ae34c62a81 100644 --- a/cmd/authctl/user/set-uid.go +++ b/cmd/authctl/user/set-uid.go @@ -60,6 +60,7 @@ Examples: resp, err := client.SetUserID(context.Background(), &authd.SetUserIDRequest{ Name: name, Id: uint32(uid), + Lang: os.Getenv("LANG"), }) if err != nil { return err diff --git a/internal/proto/authd/authd.pb.go b/internal/proto/authd/authd.pb.go index 0c66609a66..2713be4297 100644 --- a/internal/proto/authd/authd.pb.go +++ b/internal/proto/authd/authd.pb.go @@ -1170,9 +1170,12 @@ func (x *GetGroupByIDRequest) GetId() uint32 { } 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"` + 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 } @@ -1221,6 +1224,13 @@ func (x *SetUserIDRequest) GetId() uint32 { 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"` @@ -1266,9 +1276,12 @@ func (x *SetUserIDResponse) GetWarnings() []string { } 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"` + 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 } @@ -1317,6 +1330,13 @@ func (x *SetGroupIDRequest) GetId() uint32 { 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"` @@ -1928,15 +1948,17 @@ 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\"6\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\"/\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\"7\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\"0\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" + diff --git a/internal/proto/authd/authd.proto b/internal/proto/authd/authd.proto index 02bcbd105f..08588e64d3 100644 --- a/internal/proto/authd/authd.proto +++ b/internal/proto/authd/authd.proto @@ -171,6 +171,9 @@ message GetGroupByIDRequest{ 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 { @@ -180,6 +183,9 @@ message SetUserIDResponse { 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 { From d2585ba7ce4c62f33c01c83b7b983a32734bd30d Mon Sep 17 00:00:00 2001 From: Adrian Dombeck Date: Sun, 18 Jan 2026 23:53:17 +0100 Subject: [PATCH 45/49] Revert "ci: Install and load apparmor-profiles" We don't need to load the bwrap-userns-restrict AppArmor profile for the bubblewrap tests to work. In fact, we even have to circumvent the AppArmor profile (if it's loaded) for the tests to work. This reverts commit 7b926c0a669b8473daf4ecb12dfdd3e3eca51783. --- .github/actions/setup-go-tests/action.yaml | 5 +---- .github/workflows/qa.yaml | 3 +-- 2 files changed, 2 insertions(+), 6 deletions(-) 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 17b7821901..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 From b2954fef569c44eebe44bcc3202c037e9273bf68 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:10:00 +0000 Subject: [PATCH 46/49] Initial plan From f3895eeee9c01d34d0eab80f9714e006f931dbfd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:15:08 +0000 Subject: [PATCH 47/49] Add tests for getHomeDirOwner function Co-authored-by: 3v1n0 <345675+3v1n0@users.noreply.github.com> --- internal/users/export_test.go | 4 ++ internal/users/manager_test.go | 83 ++++++++++++++++++++++++++++++++++ 2 files changed, 87 insertions(+) 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/manager_test.go b/internal/users/manager_test.go index 0a2d1b12fd..5b72c42234 100644 --- a/internal/users/manager_test.go +++ b/internal/users/manager_test.go @@ -1305,6 +1305,89 @@ 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 (non-zero or matching current process) + currentUID := uint32(os.Getuid()) + currentGID := uint32(os.Getgid()) + + require.Equal(t, currentUID, uid, "UID should match current process UID") + require.Equal(t, currentGID, gid, "GID should match current process GID") + }) + } +} + func newManagerForTests(t *testing.T, dbDir string, opts ...users.Option) *users.Manager { t.Helper() From 00acfef260f75cbff7b0831e7a555e357f867203 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:17:18 +0000 Subject: [PATCH 48/49] Improve test assertions to work in diverse environments Co-authored-by: 3v1n0 <345675+3v1n0@users.noreply.github.com> --- internal/users/manager_test.go | 22 ++++++++++++++++------ 1 file changed, 16 insertions(+), 6 deletions(-) diff --git a/internal/users/manager_test.go b/internal/users/manager_test.go index 5b72c42234..345a7ec01b 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" @@ -1378,12 +1379,21 @@ func TestGetHomeDirOwner(t *testing.T) { } require.NoError(t, err, "GetHomeDirOwner should not return an error") - // Verify that UID and GID are valid (non-zero or matching current process) - currentUID := uint32(os.Getuid()) - currentGID := uint32(os.Getgid()) - - require.Equal(t, currentUID, uid, "UID should match current process UID") - require.Equal(t, currentGID, gid, "GID should match current process GID") + // 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") }) } } From e34fb3cb9c3e667acf7a63ed9520ca58e6b35a06 Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 27 Jan 2026 19:18:01 +0000 Subject: [PATCH 49/49] Remove trailing whitespace Co-authored-by: 3v1n0 <345675+3v1n0@users.noreply.github.com> --- internal/users/manager_test.go | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/internal/users/manager_test.go b/internal/users/manager_test.go index 345a7ec01b..8e80cb83bb 100644 --- a/internal/users/manager_test.go +++ b/internal/users/manager_test.go @@ -1383,14 +1383,14 @@ func TestGetHomeDirOwner(t *testing.T) { // 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")