Changes
6 changed files (+595/-0)
-
-
@@ -111,6 +111,15 @@ IsAdmin: proto.Bool(false),PasswordLogin: nil, }) p.permissions[ev.UserCreated.GetId()] = make(map[types.Permission]struct{}) case *workspaceEventsv1.Event_UserDeleted: id := ev.UserDeleted.GetId() for i := len(p.Projection.Users) - 1; i >= 0; i-- { if p.Projection.Users[i].GetId() == id { p.Projection.Users = slices.Delete(p.Projection.Users, i, i+1) } } delete(p.permissions, id) case *workspaceEventsv1.Event_AdminAccessGranted: id := ev.AdminAccessGranted.GetUserId()
-
-
-
@@ -60,6 +60,20 @@ },} } func DeleteUser(id string) *eventV1.Event { return &eventV1.Event{ Event: &eventV1.Event_WorkspaceEvent{ WorkspaceEvent: &workspaceEvent.Event{ Event: &workspaceEvent.Event_UserDeleted{ UserDeleted: &workspaceEvent.UserDeleted{ Id: proto.String(id), }, }, }, }, } } func ConfigurePasswordLogin(userID string, password string) *eventV1.Event { hash, salt := crypto.SaltAndHashPassword([]byte(password))
-
-
-
@@ -0,0 +1,179 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only package workspace import ( "context" "slices" "connectrpc.com/connect" "google.golang.org/protobuf/proto" eventV1 "pocka.jp/x/yamori/proto/go/backend/events/v1" "pocka.jp/x/yamori/proto/go/backend/workspace/v1/types" errorV1 "pocka.jp/x/yamori/proto/go/error/v1" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/backend/core" "pocka.jp/x/yamori/backend/core/event" "pocka.jp/x/yamori/backend/core/projection" workspaceEvent "pocka.jp/x/yamori/backend/events/workspace" ) func deleteUserSystemError(message string) *connect.Response[workspaceV2.DeleteUserResponse] { return connect.NewResponse(&workspaceV2.DeleteUserResponse{ Result: &workspaceV2.DeleteUserResponse_SystemError{ SystemError: &errorV1.SystemError{ Message: proto.String(message), }, }, }) } func deleteUserAuthError() *connect.Response[workspaceV2.DeleteUserResponse] { return connect.NewResponse(&workspaceV2.DeleteUserResponse{ Result: &workspaceV2.DeleteUserResponse_AuthenticationError{ AuthenticationError: &errorV1.AuthenticationError{}, }, }) } func deleteUserPermError() *connect.Response[workspaceV2.DeleteUserResponse] { return connect.NewResponse(&workspaceV2.DeleteUserResponse{ Result: &workspaceV2.DeleteUserResponse_PermissionError{ PermissionError: &errorV1.PermissionError{}, }, }) } func deleteUserMissingField(path string) *connect.Response[workspaceV2.DeleteUserResponse] { return connect.NewResponse(&workspaceV2.DeleteUserResponse{ Result: &workspaceV2.DeleteUserResponse_MissingFieldError{ MissingFieldError: &errorV1.MissingFieldError{ Path: proto.String(path), }, }, }) } func (s *Service) DeleteUser( ctx context.Context, req *connect.Request[workspaceV2.DeleteUserRequest], ) (*connect.Response[workspaceV2.DeleteUserResponse], error) { logger := s.core.Logger.With( "service", "yamori.workspace.v2.WorkspaceService", "method", "DeleteUser", ) header := req.Header() token, err := s.core.LoadTokenFromCookie(&header) if err != nil || token == nil { return deleteUserAuthError(), nil } tx, err := s.core.DB.Begin() if err != nil { logger.Error("Failed to begin transaction", "error", err) return deleteUserSystemError("Database error"), nil } defer tx.Rollback() secret, err := projection.GetLoginJwtSecret(tx) if err != nil { logger.Error("Failed to read login_jwt_secret projection", "error", err) return deleteUserSystemError("Database error"), nil } workspace, err := projection.GetWorkspace(tx) if err != nil { logger.Error("Failed to read workspace projection", "error", err) return deleteUserSystemError("Database error"), nil } users, err := projection.GetUsers(tx) if err != nil { logger.Error("Failed to read users projection", "error", err) return deleteUserSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace, secret, users); err != nil { logger.Error("Failed to update projections", "error", err) return deleteUserSystemError("Database error"), nil } user, err := token.FindUser(secret, users) if err != nil { logger.Warn("Malformed token found", "error", err) return deleteUserAuthError(), nil } if req.Msg.Id == nil { return deleteUserMissingField("id"), nil } id := req.Msg.Id.GetValue() if id == "" { return deleteUserMissingField("id.value"), nil } for _, u := range users.Projection.Users { if u.GetId() == id { requiredPerm := types.Permission_PERMISSION_DELETE_REGULAR_USER if u.GetIsAdmin() { requiredPerm = types.Permission_PERMISSION_DELETE_ADMIN_USER } if !slices.Contains(user.Permissions, requiredPerm) { return deleteUserPermError(), nil } if u.GetIsAdmin() && workspace.Projection.GetNumberOfAdmins() == 1 { return connect.NewResponse(&workspaceV2.DeleteUserResponse{ Result: &workspaceV2.DeleteUserResponse_YouAreTheOnlyAdmin{ YouAreTheOnlyAdmin: req.Msg.Id, }, }), nil } err := event.AppendEvents(tx, []*eventV1.Event{ workspaceEvent.DeleteUser(id), }) if err != nil { logger.Error("Failed to append user delete event", "error", err) return deleteUserSystemError("Database error"), nil } if err := event.UpdateProjections(tx, workspace, users); err != nil { logger.Error("Failed to update workspace and users projection") return deleteUserSystemError("Database error"), nil } if err := tx.Commit(); err != nil { logger.Error("Failed to commit transaction", "error", err) return deleteUserSystemError("Database error"), nil } res := connect.NewResponse(&workspaceV2.DeleteUserResponse{ Result: &workspaceV2.DeleteUserResponse_Ok{ Ok: projectionUserToMessage(u), }, }) if user.GetId() == id { header := res.Header() core.DeleteTokenFromCookie(&header) } return res, nil } } return connect.NewResponse(&workspaceV2.DeleteUserResponse{ Result: &workspaceV2.DeleteUserResponse_NotFound{ NotFound: &errorV1.NotFound{ TypeName: proto.String("yamori.workspace.v2.User"), }, }, }), nil }
-
-
-
@@ -0,0 +1,379 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only //go:build !js && !wasm package v2 import ( "context" "reflect" "testing" "connectrpc.com/connect" "google.golang.org/protobuf/proto" workspaceV2 "pocka.jp/x/yamori/proto/go/workspace/v2" "pocka.jp/x/yamori/proto/go/workspace/v2/v2connect" ) func TestDeleteUserGetOK(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) creation, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } created, ok := creation.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(creation.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } deletion, err := client.DeleteUser( context.Background(), connect.NewRequest(&workspaceV2.DeleteUserRequest{ Id: created.Ok.Id, }), ) if err != nil { t.Fatal(err) } deleted, ok := deletion.Msg.Result.(*workspaceV2.DeleteUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(deletion.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } if deleted.Ok.Id.GetValue() != created.Ok.Id.GetValue() { t.Fatalf("Expected ID=%s, got ID=%s", created.Ok.Id.GetValue(), deleted.Ok.Id.GetValue()) } getting, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } get, ok := getting.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(getting.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } for _, u := range get.Ok.Users { if u.Id.GetValue() == created.Ok.Id.GetValue() { t.Errorf("User not deleted: ID=%s still exists", created.Ok.Id.GetValue()) } } } func TestDeleteUserInsufficientPermission(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) bob, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), Permissions: &workspaceV2.UserPermissions{ CanAddUser: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } v, ok := bob.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bob.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", v.Ok.GetName()) } bobLogin, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := bobLogin.Msg.Result.(*workspaceV2.LoginResponse_Ok); !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } creation, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("carol"), DisplayName: proto.String("Carol"), Password: proto.String("carol_password"), }), ) created, ok := creation.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(creation.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } deletion, err := client.DeleteUser( context.Background(), connect.NewRequest(&workspaceV2.DeleteUserRequest{ Id: created.Ok.Id, }), ) if err != nil { t.Fatal(err) } _, ok = deletion.Msg.Result.(*workspaceV2.DeleteUserResponse_PermissionError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(deletion.Msg.Result)) t.Fatalf("Expected permission_error, got %s", typeName.Type().Name()) } getting, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } get, ok := getting.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(getting.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } for _, u := range get.Ok.Users { if u.Id.GetValue() == created.Ok.Id.GetValue() { return } } t.Errorf("User deleted: Cannot find ID=%s", created.Ok.Id.GetValue()) } func TestDeleteUserRejectsRegularUserDeleteingAdmin(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) gettingBefore, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } before, ok := gettingBefore.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(gettingBefore.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } var adminID string for _, u := range before.Ok.Users { if u.GetIsAdmin() { adminID = u.Id.GetValue() break } } if adminID == "" { t.Fatal("No admin found: test helper did not create admin user") } bob, err := client.CreateUser( context.Background(), connect.NewRequest(&workspaceV2.CreateUserRequest{ Name: proto.String("bob"), DisplayName: proto.String("Bob"), Password: proto.String("bob_password"), Permissions: &workspaceV2.UserPermissions{ CanDeleteRegularUser: proto.Bool(true), }, }), ) if err != nil { t.Fatal(err) } v, ok := bob.Msg.Result.(*workspaceV2.CreateUserResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(bob.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } if v.Ok.GetName() != "bob" { t.Errorf("Expected bob, got %s", v.Ok.GetName()) } bobLogin, err := client.Login( context.Background(), connect.NewRequest(&workspaceV2.LoginRequest{ Name: proto.String("bob"), Password: proto.String("bob_password"), }), ) if err != nil { t.Fatal(err) } if _, ok := bobLogin.Msg.Result.(*workspaceV2.LoginResponse_Ok); !ok { typeName := reflect.Indirect(reflect.ValueOf(bobLogin.Msg.Result)) t.Errorf("Expected ok, got %s", typeName.Type().Name()) } deletion, err := client.DeleteUser( context.Background(), connect.NewRequest(&workspaceV2.DeleteUserRequest{ Id: &workspaceV2.UserID{ Value: proto.String(adminID), }, }), ) if err != nil { t.Fatal(err) } _, ok = deletion.Msg.Result.(*workspaceV2.DeleteUserResponse_PermissionError) if !ok { typeName := reflect.Indirect(reflect.ValueOf(deletion.Msg.Result)) t.Fatalf("Expected permission_error, got %s", typeName.Type().Name()) } gettingAfter, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } after, ok := gettingAfter.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(gettingAfter.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } for _, u := range after.Ok.Users { if u.Id.GetValue() == adminID { return } } t.Errorf("User deleted: Cannot find ID=%s", adminID) } func TestDeleteUserRejectsOnlyAdminDeletingSelf(t *testing.T) { server, jar := setupLogin(t) httpClient := server.Client() httpClient.Jar = jar client := v2connect.NewWorkspaceServiceClient(httpClient, server.URL()) gettingBefore, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } before, ok := gettingBefore.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(gettingBefore.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } var adminID string for _, u := range before.Ok.Users { if u.GetIsAdmin() { adminID = u.Id.GetValue() break } } if adminID == "" { t.Fatal("No admin found: test helper did not create admin user") } deletion, err := client.DeleteUser( context.Background(), connect.NewRequest(&workspaceV2.DeleteUserRequest{ Id: &workspaceV2.UserID{ Value: proto.String(adminID), }, }), ) if err != nil { t.Fatal(err) } _, ok = deletion.Msg.Result.(*workspaceV2.DeleteUserResponse_YouAreTheOnlyAdmin) if !ok { typeName := reflect.Indirect(reflect.ValueOf(deletion.Msg.Result)) t.Fatalf("Expected you_are_the_only_admin, got %s", typeName.Type().Name()) } gettingAfter, err := client.Get( context.Background(), connect.NewRequest(&workspaceV2.GetRequest{}), ) if err != nil { t.Fatal(err) } after, ok := gettingAfter.Msg.Result.(*workspaceV2.GetResponse_Ok) if !ok { typeName := reflect.Indirect(reflect.ValueOf(gettingAfter.Msg.Result)) t.Fatalf("Expected ok, got %s", typeName.Type().Name()) } for _, u := range after.Ok.Users { if u.Id.GetValue() == adminID { return } } t.Errorf("User deleted: Cannot find ID=%s", adminID) }
-
-
-
@@ -16,6 +16,7 @@ import "yamori/backend/events/workspace/v1/custom_attribute_undefined.proto";import "yamori/backend/events/workspace/v1/login_jwt_secret_configured.proto"; import "yamori/backend/events/workspace/v1/password_login_configured.proto"; import "yamori/backend/events/workspace/v1/user_created.proto"; import "yamori/backend/events/workspace/v1/user_deleted.proto"; import "yamori/backend/events/workspace/v1/user_permissions_granted.proto"; import "yamori/backend/events/workspace/v1/user_permissions_revoked.proto"; import "yamori/backend/events/workspace/v1/workspace_display_name_set.proto";
-
@@ -38,5 +39,6 @@ AbbreviationsConfigured abbreviations_configured = 11;CustomAttributeDefined custom_attribute_defined = 12; CustomAttributeUndefined custom_attribute_undefined = 13; CustomAttributeSet custom_attribute_set = 14; UserDeleted user_deleted = 15; } }
-
-
-
@@ -0,0 +1,12 @@// SPDX-FileCopyrightText: 2025 Shota FUJI <pockawoooh@gmail.com> // SPDX-License-Identifier: AGPL-3.0-only edition = "2023"; package yamori.backend.events.workspace.v1; option go_package = "pocka.jp/x/yamori/proto/go/backend/events/workspace/v1"; message UserDeleted { string id = 1; }
-