fix(migration): map exported assignees to the importing user instead of failing

Vikunja-file exports carry task assignees with user ids from the source
instance. On import those ids were looked up on the target instance and
permission-checked, which fails with "User does not have access to the
project" whenever the id belongs to someone else (e.g. importing a
self-hosted export into Vikunja Cloud). Since ba980b1b8 that error aborts
the whole import.

Match assignees against the importing user by email, then username, and
drop everyone else – foreign user ids have no meaning on this instance.

Fixes #3476
This commit is contained in:
kolaente
2026-08-19 15:10:01 +02:00
parent 3108e6e208
commit bd1f95bb96
2 changed files with 66 additions and 2 deletions
+28 -2
View File
@@ -20,6 +20,7 @@ import (
"bytes"
"context"
"math"
"strings"
"xorm.io/xorm"
@@ -34,11 +35,18 @@ import (
// InsertFromStructure takes a fully nested Vikunja data structure and a user and then creates everything for this user
// (Projects, tasks, etc. Even attachments and relations.)
func InsertFromStructure(str []*models.ProjectWithTasksAndBuckets, user *user.User) (err error) {
func InsertFromStructure(str []*models.ProjectWithTasksAndBuckets, u *user.User) (err error) {
s := db.NewSession()
defer s.Close()
err = insertFromStructure(s, str, user)
// Callers may pass a user built from jwt claims; load the stored one so
// assignee matching sees the current email/username.
importer, err := user.GetUserWithEmail(s, &user.User{ID: u.ID})
if err != nil {
return err
}
err = insertFromStructure(s, str, importer)
if err != nil {
log.Errorf("[creating structure] Error while creating structure: %s", err.Error())
_ = s.Rollback()
@@ -355,6 +363,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
t.ProjectID = project.ID
originalBucketID := t.BucketID
t.BucketID = 0
t.Assignees = remapAssignees(t.Assignees, user)
err = t.Create(s, user)
if err != nil {
if models.IsErrTaskCannotBeEmpty(err) {
@@ -393,6 +402,7 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
rt.ProjectID = t.ProjectID
originalBucketID := rt.BucketID
rt.BucketID = 0
rt.Assignees = remapAssignees(rt.Assignees, user)
err = rt.Create(s, user)
if err != nil {
@@ -606,3 +616,19 @@ func createProjectWithEverything(s *xorm.Session, project *models.ProjectWithTas
return nil
}
// Exported assignees carry user ids from a foreign instance. Only the importing
// user can be matched (by email, then username); everyone else is dropped.
func remapAssignees(assignees []*user.User, importer *user.User) []*user.User {
for _, a := range assignees {
if a == nil {
continue
}
emailMatch := a.Email != "" && importer.Email != "" && strings.EqualFold(a.Email, importer.Email)
usernameMatch := a.Username != "" && strings.EqualFold(a.Username, importer.Username)
if emailMatch || usernameMatch {
return []*user.User{importer}
}
}
return nil
}
@@ -277,4 +277,42 @@ func TestInsertFromStructure(t *testing.T) {
assert.Equal(t, int64(4), count, "task %q must keep position %v in all views", title, position)
}
})
t.Run("assignees from a foreign instance", func(t *testing.T) {
db.LoadAndAssertFixtures(t)
foreignID := int64(999)
require.NoError(t, InsertFromStructure([]*models.ProjectWithTasksAndBuckets{{
Project: models.Project{Title: "Import project"},
Tasks: []*models.TaskWithComments{
{Task: models.Task{Title: "email match", Assignees: []*user.User{{ID: foreignID, Username: "someone-else", Email: "USER1@example.com"}}}},
{Task: models.Task{Title: "username match", Assignees: []*user.User{{ID: foreignID, Username: "user1"}}}},
{Task: models.Task{Title: "no match", Assignees: []*user.User{{ID: 2, Username: "other", Email: "other@example.com"}}}},
{Task: models.Task{
Title: "related",
RelatedTasks: map[models.RelationKind][]*models.Task{
models.RelationKindSubtask: {{Title: "related match", Assignees: []*user.User{{ID: foreignID, Username: "user1"}}}},
},
}},
},
}}, u))
s := db.NewSession()
defer s.Close()
for title, wantAssignee := range map[string]bool{"email match": true, "username match": true, "related match": true, "no match": false} {
task := &models.Task{}
exists, err := s.Where("title = ?", title).Get(task)
require.NoError(t, err)
require.True(t, exists, title)
assignees := []*models.TaskAssginee{}
require.NoError(t, s.Where("task_id = ?", task.ID).Find(&assignees))
if wantAssignee {
require.Len(t, assignees, 1, title)
assert.Equal(t, u.ID, assignees[0].UserID, title)
} else {
assert.Empty(t, assignees, title)
}
}
})
}