Files
xinmi/internal/accesscontrol/resource_version_knowledge_authorization_test.go
T

203 lines
7.6 KiB
Go
Raw Normal View History

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")
}
}