package accesscontrol import ( "context" "errors" "testing" "github.com/Tencent/WeKnora/internal/accesspolicy" "github.com/Tencent/WeKnora/internal/types" "github.com/google/uuid" ) func TestKnowledgeVersionRechecksRevokedAuthorization(t *testing.T) { for _, purpose := range []string{"fork", "submission"} { for _, revoke := range []string{"inactive", "membership", "role", "module", "publication", "deleted_user"} { for _, phase := range []string{"queued", "document", "copied"} { t.Run(purpose+"/"+revoke+"/"+phase, func(t *testing.T) { s, adapter, source := knowledgeVersionFixture(t) if phase == "document" { doc := types.Knowledge{ID: uuid.NewString(), TenantID: 1, KnowledgeBaseID: source, Title: "second document", Type: "manual", ParseStatus: types.ParseStatusCompleted, CustomMetadata: types.JSON(`{}`)} if err := s.db.Create(&doc).Error; err != nil { t.Fatal(err) } } ctx := versionContext(1, "alice", false) fork, err := s.forkResource(ctx, "knowledge", source) if err != nil { t.Fatal(err) } var submission *ResourceSubmission if purpose == "submission" { runVersionJob(t, s) refreshFork(t, s, fork) submission, err = s.submitResourceVersion(ctx, fork.ID, "review") if err != nil { t.Fatal(err) } } invalidate := func() { var err error switch revoke { case "inactive": err = s.db.Table("users").Where("id=?", "alice").Update("is_active", false).Error case "membership": err = s.db.Model(&types.TenantMember{}).Where("tenant_id=1 AND user_id=?", "alice").Update("status", types.TenantMemberStatusSuspended).Error case "role": err = s.db.Model(&types.TenantMember{}).Where("tenant_id=1 AND user_id=?", "alice").Update("role", types.TenantRoleViewer).Error case "module": role := Role{ID: "limited", TenantID: 1, Name: "limited", Permissions: types.StringArray{"knowledge.read"}} if err = s.db.Create(&role).Error; err == nil { err = s.db.Create(&Assignment{TenantID: 1, UserID: "alice", RoleID: role.ID}).Error } case "publication": err = s.db.Model(&Publication{}).Where("tenant_id=1 AND resource_id=?", source).Update("global", false).Error case "deleted_user": err = s.db.Delete(&types.User{}, "id=?", "alice").Error } if err != nil { t.Fatal(err) } } switch phase { case "queued": invalidate() case "document": adapter.afterDocument = func(n int) { if n == 1 { invalidate() } } case "copied": adapter.afterCopy = invalidate } runVersionJob(t, s) var job KnowledgeVersionJob if err := s.db.Where("fork_id=? AND purpose=?", fork.ID, purpose).First(&job).Error; err != nil { t.Fatal(err) } refreshFork(t, s, fork) if job.Status != "failed" || job.Error == "" { t.Fatalf("revoked copy was exposed: job=%s error=%s", job.Status, job.Error) } if submission == nil { if fork.Status != "failed" { t.Fatalf("revoked fork status=%s", fork.Status) } } else { if err := s.db.First(submission, "id=?", submission.ID).Error; err != nil { t.Fatal(err) } if submission.Status != "snapshot_failed" || fork.Status != "ready" { t.Fatalf("revoked submission=%s fork=%s", submission.Status, fork.Status) } } if phase == "document" { var copied int64 if err := s.db.Model(&types.Knowledge{}).Where("knowledge_base_id=?", job.TargetID).Count(&copied).Error; err != nil { t.Fatal(err) } if copied != 1 || job.Total != 2 { t.Fatalf("copy continued after document authorization revoked: copied=%d total=%d", copied, job.Total) } } var target types.KnowledgeBase if err := s.db.First(&target, "id=?", job.TargetID).Error; err != nil { t.Fatal(err) } if target.VersionState == "" || !target.IsTemporary { t.Fatal("failed copy must remain inaccessible staging data") } }) } } } } func TestKnowledgeVersionCrossTenantAuthorization(t *testing.T) { for _, tc := range []struct { name string homeTenant uint64 viewer bool revokeAccess bool allowed bool }{ {name: "all_access_without_membership", homeTenant: 2, allowed: true}, {name: "all_access_revoked", homeTenant: 2, revokeAccess: true}, {name: "home_tenant_without_membership", homeTenant: 1}, {name: "active_viewer_not_elevated", homeTenant: 2, viewer: true}, } { t.Run(tc.name, func(t *testing.T) { s, _, source := knowledgeVersionFixture(t) if err := s.db.Table("users").Where("id=?", "alice").Updates(map[string]any{"tenant_id": tc.homeTenant, "can_access_all_tenants": true}).Error; err != nil { t.Fatal(err) } if tc.viewer { if err := s.db.Model(&types.TenantMember{}).Where("tenant_id=1 AND user_id=?", "alice").Update("role", types.TenantRoleViewer).Error; err != nil { t.Fatal(err) } } else if err := s.db.Unscoped().Where("tenant_id=1 AND user_id=?", "alice").Delete(&types.TenantMember{}).Error; err != nil { t.Fatal(err) } fork, err := s.forkResource(versionContext(1, "alice", true), "knowledge", source) if err != nil { t.Fatal(err) } job := forkVersionJob(t, s, fork) if tc.revokeAccess { if _, err := s.authorizeKnowledgeVersionJob(context.Background(), job); err != nil { t.Fatalf("cross-tenant job was not authorized before revocation: %v", err) } if err := s.db.Table("users").Where("id=?", "alice").Update("can_access_all_tenants", false).Error; err != nil { t.Fatal(err) } } ctx, err := s.authorizeKnowledgeVersionJob(context.Background(), job) if tc.allowed { if err != nil || types.TenantRoleFromContext(ctx) != types.TenantRoleAdmin || types.IsSystemAdminFromContext(ctx) || accesspolicy.FromContext(ctx) == nil { t.Fatalf("cross-tenant identity was not restored correctly: role=%s err=%v", types.TenantRoleFromContext(ctx), err) } } else if !errors.Is(err, errVersionDenied) { t.Fatalf("expected revoked or insufficient access to be denied, got %v", err) } if tc.viewer && types.TenantRoleFromContext(ctx) != types.TenantRoleViewer { t.Fatalf("active viewer membership was elevated to %s", types.TenantRoleFromContext(ctx)) } runVersionJob(t, s) job = forkVersionJob(t, s, fork) refreshFork(t, s, fork) if tc.allowed { if job.Status != "completed" || fork.Status != "ready" { t.Fatalf("authorized cross-tenant job failed: job=%s fork=%s", job.Status, fork.Status) } } else if job.Status != "failed" || fork.Status != "failed" { t.Fatalf("unauthorized cross-tenant job completed: job=%s fork=%s", job.Status, fork.Status) } if !tc.viewer { var members int64 if err := s.db.Unscoped().Model(&types.TenantMember{}).Where("tenant_id=1 AND user_id=?", "alice").Count(&members).Error; err != nil { t.Fatal(err) } if members != 0 { t.Fatal("worker created a persistent cross-tenant membership") } } }) } } func TestKnowledgeVersionWorkerRetainsActualRole(t *testing.T) { s, _, source := knowledgeVersionFixture(t) fork, err := s.forkResource(versionContext(1, "alice", false), "knowledge", source) if err != nil { t.Fatal(err) } ctx, err := s.authorizeKnowledgeVersionJob(context.Background(), forkVersionJob(t, s, fork)) if err != nil { t.Fatal(err) } if types.TenantRoleFromContext(ctx) != types.TenantRoleContributor || types.IsSystemAdminFromContext(ctx) || accesspolicy.FromContext(ctx) == nil { t.Fatal("copy worker escalated identity or discarded resource permissions") } }