Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
203 lines
7.6 KiB
Go
203 lines
7.6 KiB
Go
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")
|
|
}
|
|
}
|