WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
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
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
This commit is contained in:
@@ -0,0 +1,202 @@
|
||||
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")
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user