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
163 lines
12 KiB
Go
163 lines
12 KiB
Go
package repository
|
|
|
|
import (
|
|
"context"
|
|
"testing"
|
|
|
|
"github.com/Tencent/WeKnora/internal/accesspolicy"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
"github.com/Tencent/WeKnora/internal/types/interfaces"
|
|
"github.com/stretchr/testify/require"
|
|
"gorm.io/driver/sqlite"
|
|
"gorm.io/gorm"
|
|
)
|
|
|
|
func fileAccessFixture(t *testing.T) (*resourceRepository, *gorm.DB, string) {
|
|
t.Helper()
|
|
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
|
|
require.NoError(t, err)
|
|
sqlDB, err := db.DB()
|
|
require.NoError(t, err)
|
|
sqlDB.SetMaxOpenConns(1)
|
|
t.Cleanup(func() { _ = sqlDB.Close() })
|
|
require.NoError(t, db.AutoMigrate(&types.StoredResource{}, &types.ResourceBinding{}))
|
|
for _, schema := range []string{
|
|
"CREATE TABLE knowledges (id TEXT PRIMARY KEY, tenant_id INTEGER, knowledge_base_id TEXT, file_path TEXT, deleted_at DATETIME)",
|
|
"CREATE TABLE knowledge_bases (id TEXT PRIMARY KEY, tenant_id INTEGER, creator_id TEXT, is_temporary BOOLEAN DEFAULT FALSE, version_state TEXT DEFAULT '', deleted_at DATETIME)",
|
|
"CREATE TABLE sessions (id TEXT PRIMARY KEY, tenant_id INTEGER, user_id TEXT, description TEXT, deleted_at DATETIME)",
|
|
"CREATE TABLE messages (id TEXT PRIMARY KEY, session_id TEXT, knowledge_references TEXT, deleted_at DATETIME)",
|
|
"CREATE TABLE temporary_documents (id TEXT PRIMARY KEY, tenant_id INTEGER, session_id TEXT, expires_at DATETIME, deleted_at DATETIME)",
|
|
"CREATE TABLE custom_agents (id TEXT PRIMARY KEY, tenant_id INTEGER, enabled BOOLEAN, config TEXT, deleted_at DATETIME)",
|
|
"CREATE TABLE embed_channels (id TEXT PRIMARY KEY, tenant_id INTEGER, agent_id TEXT, enabled BOOLEAN, deleted_at DATETIME)",
|
|
} {
|
|
require.NoError(t, db.Exec(schema).Error)
|
|
}
|
|
resource := &types.StoredResource{ID: "file-1", Handle: "AbCdEfGhIjKlMnOpQrStUv", TenantID: 7, PhysicalPath: "local://7/exports/private.png", Provider: "local", LocationHash: "unused"}
|
|
require.NoError(t, db.Create(resource).Error)
|
|
return &resourceRepository{db: db}, db, types.BuildResourcePath(resource.Handle)
|
|
}
|
|
|
|
func fileUserContext(user string) context.Context {
|
|
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(7))
|
|
ctx = context.WithValue(ctx, types.UserIDContextKey, user)
|
|
return accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{Publications: map[string]bool{"knowledge:kb-a": false}})
|
|
}
|
|
|
|
func TestResourceFileAccessKnowledgeBinding(t *testing.T) {
|
|
r, db, ref := fileAccessFixture(t)
|
|
require.NoError(t, db.Exec("INSERT INTO knowledge_bases (id,tenant_id,creator_id) VALUES ('kb-a',7,'alice'),('kb-b',7,'bob')").Error)
|
|
require.NoError(t, db.Exec("INSERT INTO knowledges (id,tenant_id,knowledge_base_id,file_path) VALUES ('doc-a',7,'kb-a','local://7/legacy.doc')").Error)
|
|
require.NoError(t, r.CreateBinding(context.Background(), &types.ResourceBinding{ResourceID: "file-1", TenantID: 7, OwnerType: types.ResourceOwnerKnowledge, OwnerID: "doc-a"}))
|
|
scope := interfaces.ResourceFileScope{TenantID: 7}
|
|
require.NoError(t, r.AuthorizeFileRead(fileUserContext("alice"), ref, scope))
|
|
require.Error(t, r.AuthorizeFileRead(fileUserContext("bob"), ref, scope))
|
|
require.Error(t, r.AuthorizeFileRead(fileUserContext("bob"), "local://7/legacy.doc", scope))
|
|
require.NoError(t, r.AuthorizeFileRead(fileUserContext("alice"), "local://7/legacy.doc", scope))
|
|
require.Error(t, r.AuthorizeFileRead(fileUserContext("alice"), "local://7/unregistered.png", scope))
|
|
require.Error(t, r.AuthorizeFileRead(fileUserContext("alice"), ref, interfaces.ResourceFileScope{TenantID: 8}))
|
|
require.Error(t, r.AuthorizeFileRead(fileUserContext("bob"), ref, interfaces.ResourceFileScope{TenantID: 7, KnowledgeBaseID: "kb-b"}))
|
|
require.NoError(t, r.AuthorizeFileRead(fileUserContext("bob"), ref, interfaces.ResourceFileScope{TenantID: 7, KnowledgeBaseID: "kb-a"}))
|
|
shared := accesspolicy.WithSnapshot(fileUserContext("bob"), &accesspolicy.Snapshot{Publications: map[string]bool{"knowledge:kb-a": false}, Grants: map[string]bool{"knowledge:kb-a": true}})
|
|
require.NoError(t, r.AuthorizeFileRead(shared, ref, scope))
|
|
denied := accesspolicy.WithSnapshot(fileUserContext("alice"), &accesspolicy.Snapshot{Restricted: true, Permissions: map[string]bool{}})
|
|
require.Error(t, r.AuthorizeFileRead(denied, ref, scope))
|
|
require.NoError(t, db.Exec("UPDATE knowledges SET deleted_at=CURRENT_TIMESTAMP WHERE id='doc-a'").Error)
|
|
require.Error(t, r.AuthorizeFileRead(fileUserContext("alice"), ref, scope))
|
|
}
|
|
|
|
func TestResourceFileAccessSharedOriginalCannotBeDownloaded(t *testing.T) {
|
|
r, db, ref := fileAccessFixture(t)
|
|
require.NoError(t, db.Exec("INSERT INTO knowledge_bases (id,tenant_id,creator_id) VALUES ('kb-a',7,'alice')").Error)
|
|
require.NoError(t, db.Exec("INSERT INTO knowledges (id,tenant_id,knowledge_base_id,file_path) VALUES ('doc-a',7,'kb-a','local://7/legacy.doc')").Error)
|
|
require.NoError(t, r.CreateBinding(context.Background(), &types.ResourceBinding{ResourceID: "file-1", TenantID: 7, OwnerType: types.ResourceOwnerKnowledge, OwnerID: "doc-a", Relation: types.ResourceRelationSourceFile}))
|
|
require.NoError(t, r.CreateBinding(context.Background(), &types.ResourceBinding{ResourceID: "file-1", TenantID: 7, OwnerType: types.ResourceOwnerKnowledge, OwnerID: "doc-a", Relation: types.ResourceRelationAttachment}))
|
|
require.NoError(t, db.Exec("INSERT INTO sessions (id,tenant_id,user_id) VALUES ('s-b',7,'bob')").Error)
|
|
require.NoError(t, db.Exec("INSERT INTO messages (id,session_id,knowledge_references) VALUES ('msg-b','s-b',?)", []byte(`[{"knowledge_id":"doc-a"}]`)).Error)
|
|
shared := accesspolicy.WithSnapshot(fileUserContext("bob"), &accesspolicy.Snapshot{Publications: map[string]bool{"knowledge:kb-a": true}, Grants: map[string]bool{"knowledge:kb-a": true}})
|
|
for _, scope := range []interfaces.ResourceFileScope{
|
|
{TenantID: 7},
|
|
{TenantID: 7, KnowledgeBaseID: "kb-a"},
|
|
{TenantID: 7, MessageID: "msg-b", SessionID: "s-b"},
|
|
{TenantID: 7, EmbedChannelID: "channel-a"},
|
|
} {
|
|
require.ErrorIs(t, r.AuthorizeFileRead(shared, ref, scope), errFileAccessDenied)
|
|
require.ErrorIs(t, r.AuthorizeFileRead(shared, "local://7/legacy.doc", scope), errFileAccessDenied)
|
|
}
|
|
scope := interfaces.ResourceFileScope{TenantID: 7}
|
|
require.NoError(t, r.AuthorizeFileRead(fileUserContext("alice"), ref, scope))
|
|
admin := context.WithValue(fileUserContext("admin"), types.TenantRoleContextKey, types.TenantRoleAdmin)
|
|
require.NoError(t, r.AuthorizeFileRead(admin, ref, scope))
|
|
systemAdmin := context.WithValue(fileUserContext("system"), types.SystemAdminContextKey, true)
|
|
require.NoError(t, r.AuthorizeFileRead(systemAdmin, ref, scope))
|
|
crossTenant := context.WithValue(systemAdmin, types.TenantIDContextKey, uint64(8))
|
|
require.ErrorIs(t, r.AuthorizeFileRead(crossTenant, ref, scope), errFileAccessDenied)
|
|
denied := accesspolicy.WithSnapshot(fileUserContext("alice"), &accesspolicy.Snapshot{Restricted: true, Permissions: map[string]bool{}})
|
|
require.ErrorIs(t, r.AuthorizeFileRead(denied, ref, interfaces.ResourceFileScope{TenantID: 7, KnowledgeBaseID: "kb-a"}), errFileAccessDenied)
|
|
|
|
// A legacy registration without a source relation is still an original.
|
|
require.NoError(t, db.Exec("DELETE FROM resource_bindings WHERE relation=?", types.ResourceRelationSourceFile).Error)
|
|
for _, location := range []string{ref, "local://7/exports/private.png"} {
|
|
require.NoError(t, db.Exec("UPDATE knowledges SET file_path=? WHERE id='doc-a'", location).Error)
|
|
require.ErrorIs(t, r.AuthorizeFileRead(shared, ref, scope), errFileAccessDenied)
|
|
require.NoError(t, r.AuthorizeFileRead(fileUserContext("alice"), ref, scope))
|
|
}
|
|
}
|
|
|
|
func TestResourceFileAccessPrivateMessages(t *testing.T) {
|
|
r, db, ref := fileAccessFixture(t)
|
|
require.NoError(t, db.Exec("INSERT INTO sessions (id,tenant_id,user_id) VALUES ('s-a',7,'alice'),('s-b',7,'bob'),('s-legacy',7,'')").Error)
|
|
require.NoError(t, db.Exec("INSERT INTO messages (id,session_id) VALUES ('msg-a','s-a'),('msg-b','s-b'),('msg-legacy','s-legacy')").Error)
|
|
binding := &types.ResourceBinding{ResourceID: "file-1", TenantID: 7, OwnerType: types.ResourceOwnerMessage, OwnerID: "msg-a"}
|
|
require.NoError(t, r.CreateBinding(context.Background(), binding))
|
|
scope := interfaces.ResourceFileScope{TenantID: 7}
|
|
require.NoError(t, r.AuthorizeFileRead(fileUserContext("alice"), ref, scope))
|
|
require.Error(t, r.AuthorizeFileRead(fileUserContext("bob"), ref, scope))
|
|
require.Error(t, r.AuthorizeFileRead(fileUserContext("alice"), ref, interfaces.ResourceFileScope{TenantID: 7, MessageID: "msg-b", SessionID: "s-b"}))
|
|
require.NoError(t, r.AuthorizeFileRead(fileUserContext("alice"), ref, interfaces.ResourceFileScope{TenantID: 7, MessageID: "msg-a", SessionID: "s-a"}))
|
|
require.Error(t, r.AuthorizeFileRead(fileUserContext("alice"), ref, interfaces.ResourceFileScope{TenantID: 7, KnowledgeBaseID: "kb-a"}))
|
|
require.NoError(t, db.Model(&types.ResourceBinding{}).Where("id = ?", binding.ID).Update("owner_id", "msg-legacy").Error)
|
|
require.Error(t, r.AuthorizeFileRead(fileUserContext("bob"), ref, scope))
|
|
}
|
|
|
|
func TestResourceFileAccessEmbedKnowledgeScope(t *testing.T) {
|
|
r, db, ref := fileAccessFixture(t)
|
|
require.NoError(t, db.Exec("INSERT INTO knowledge_bases (id,tenant_id,creator_id) VALUES ('kb-a',7,'alice')").Error)
|
|
require.NoError(t, db.Exec("INSERT INTO knowledges (id,tenant_id,knowledge_base_id) VALUES ('doc-a',7,'kb-a')").Error)
|
|
require.NoError(t, r.CreateBinding(context.Background(), &types.ResourceBinding{ResourceID: "file-1", TenantID: 7, OwnerType: types.ResourceOwnerKnowledge, OwnerID: "doc-a"}))
|
|
require.NoError(t, db.Exec("INSERT INTO custom_agents (id,tenant_id,enabled,config) VALUES ('agent-a',7,TRUE,?)", `{"kb_selection_mode":"selected","knowledge_bases":["kb-a"]}`).Error)
|
|
require.NoError(t, db.Exec("INSERT INTO embed_channels (id,tenant_id,agent_id,enabled) VALUES ('channel-a',7,'agent-a',TRUE)").Error)
|
|
scope := interfaces.ResourceFileScope{TenantID: 7, EmbedChannelID: "channel-a"}
|
|
require.NoError(t, r.AuthorizeFileRead(fileUserContext("visitor"), ref, scope))
|
|
for _, config := range []string{`{"kb_selection_mode":"none"}`, `{"kb_selection_mode":"selected","knowledge_bases":["kb-other"]}`, `{"kb_selection_mode":"selected","knowledge_bases":[]}`} {
|
|
require.NoError(t, db.Exec("UPDATE custom_agents SET config=?", config).Error)
|
|
require.Error(t, r.AuthorizeFileRead(fileUserContext("visitor"), ref, scope))
|
|
}
|
|
}
|
|
|
|
func TestResourceFileAccessMessageKnowledgeReferences(t *testing.T) {
|
|
r, db, ref := fileAccessFixture(t)
|
|
require.NoError(t, db.Exec("INSERT INTO knowledges (id,tenant_id,knowledge_base_id) VALUES ('doc-a',7,'kb-a')").Error)
|
|
require.NoError(t, r.CreateBinding(context.Background(), &types.ResourceBinding{ResourceID: "file-1", TenantID: 7, OwnerType: types.ResourceOwnerKnowledge, OwnerID: "doc-a"}))
|
|
require.NoError(t, db.Exec("INSERT INTO sessions (id,tenant_id,user_id) VALUES ('s-a',7,'alice')").Error)
|
|
require.NoError(t, db.Exec("INSERT INTO messages (id,session_id,knowledge_references) VALUES ('msg-a','s-a',?)", []byte(`[{"knowledge_id":"doc-a"}]`)).Error)
|
|
scope := interfaces.ResourceFileScope{TenantID: 7, MessageID: "msg-a", SessionID: "s-a"}
|
|
require.NoError(t, r.AuthorizeFileRead(fileUserContext("alice"), ref, scope))
|
|
require.Error(t, r.AuthorizeFileRead(fileUserContext("bob"), ref, scope))
|
|
require.NoError(t, db.Exec("UPDATE messages SET knowledge_references=?", []byte(`[{"knowledge_id":"doc-unrelated"}]`)).Error)
|
|
require.Error(t, r.AuthorizeFileRead(fileUserContext("alice"), ref, scope))
|
|
}
|
|
|
|
func TestResourceFileAccessEmbedPrivateSession(t *testing.T) {
|
|
r, db, ref := fileAccessFixture(t)
|
|
principal := types.EmbedSessionPrincipal(7, "channel-a", "s-a")
|
|
require.NoError(t, db.Exec("INSERT INTO sessions (id,tenant_id,user_id) VALUES ('s-a',7,?)", principal.StorageID()).Error)
|
|
require.NoError(t, db.Exec("INSERT INTO messages (id,session_id) VALUES ('msg-a','s-a')").Error)
|
|
require.NoError(t, r.CreateBinding(context.Background(), &types.ResourceBinding{ResourceID: "file-1", TenantID: 7, OwnerType: types.ResourceOwnerMessage, OwnerID: "msg-a"}))
|
|
ctx := types.WithPrincipal(fileUserContext("embed"), principal)
|
|
scope := interfaces.ResourceFileScope{TenantID: 7, EmbedChannelID: "channel-a", SessionID: "s-a"}
|
|
require.NoError(t, r.AuthorizeFileRead(ctx, ref, scope))
|
|
require.Error(t, r.AuthorizeFileRead(fileUserContext("embed"), ref, scope))
|
|
scope.SessionID = "s-other"
|
|
require.Error(t, r.AuthorizeFileRead(ctx, ref, scope))
|
|
}
|