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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
@@ -0,0 +1,119 @@
package accesscontrol
import (
"net/http"
"net/http/httptest"
"slices"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
func TestGranularActionsDoNotRequireGenericWrite(t *testing.T) {
for _, tc := range []struct{ method, route, path, permission string }{
{"POST", "/skills/instructions/mount", "/skills/instructions/mount", "agents.skills"},
{"POST", "/memory/consolidate", "/memory/consolidate", "agents.memory"},
{"DELETE", "/agents/:id", "/agents/owned", "agents.delete"},
{"POST", "/digital-humans/:id/distill", "/digital-humans/owned/distill", "digital-humans.distill"},
{"POST", "/digital-humans/:id/review", "/digital-humans/owned/review", "digital-humans.review"},
{"DELETE", "/digital-humans/:id", "/digital-humans/owned", "digital-humans.delete"},
{"POST", "/wechat/qrcode", "/wechat/qrcode", "digital-humans.wechat"},
{"PATCH", "/workbench/operations/consumption", "/workbench/operations/consumption", "knowledge.consumption"},
{"DELETE", "/workbench/resources/:kind/:id", "/workbench/resources/flows/owned", "flows.delete"},
{"POST", "/workbench/resources/:kind/:id/actions/:action", "/workbench/resources/training-maps/owned/actions/generate", "content.maps"},
{"POST", "/workbench/resources/:kind/:id/actions/:action", "/workbench/resources/content-tasks/owned/actions/decompose", "content.generate"},
{"POST", "/workbench/resources/:kind/:id/actions/:action", "/workbench/resources/content-tasks/owned/actions/compose", "content.review"},
} {
t.Run(tc.permission, func(t *testing.T) {
s := versionDB(t)
module, _, _ := strings.Cut(tc.permission, ".")
permissions := types.StringArray{module + ".read", tc.permission}
if !validPermissions(permissions) || validPermissions([]string{tc.permission}) {
t.Fatal("action must require only its module read prerequisite")
}
role := Role{ID: "precise", TenantID: 1, Name: "precise", PermissionVersion: currentPermissionVersion, Permissions: permissions}
if err := s.db.Create(&role).Error; err != nil {
t.Fatal(err)
}
if err := s.db.Create(&Assignment{TenantID: 1, UserID: "alice", RoleID: role.ID}).Error; err != nil {
t.Fatal(err)
}
calls := 0
router := gin.New()
router.Use(s.Middleware())
router.Handle(tc.method, "/api/v1"+tc.route, func(c *gin.Context) { calls++; c.Status(http.StatusNoContent) })
request := func() int {
req := httptest.NewRequest(tc.method, "/api/v1"+tc.path, nil).WithContext(versionContext(1, "alice", false))
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
return res.Code
}
if status := request(); status != http.StatusNoContent {
t.Fatalf("explicit action blocked: %d", status)
}
if err := s.db.Model(&role).Update("permissions", types.StringArray{module + ".read"}).Error; err != nil {
t.Fatal(err)
}
if status := request(); status != http.StatusForbidden || calls != 1 {
t.Fatalf("revocation ignored: status=%d calls=%d", status, calls)
}
})
}
}
func TestManagementEndpointsCannotBeGrantedToOrdinaryUsers(t *testing.T) {
for _, tc := range []struct{ method, route, path string }{
{"GET", "/evaluation", "/evaluation"},
{"GET", "/tenants/:id/members", "/tenants/1/members"},
{"PUT", "/tenants/kv/:key", "/tenants/kv/config"},
{"GET", "/me/env-vars", "/me/env-vars"},
{"GET", "/workbench/operations/records/:recordKind", "/workbench/operations/records/usage"},
} {
t.Run(tc.path, func(t *testing.T) {
s := versionDB(t)
role := Role{ID: "attempted-admin", TenantID: 1, Name: "attempted-admin", PermissionVersion: currentPermissionVersion, Permissions: types.StringArray{"settings.read", "settings.write", "usage.read"}}
if err := s.db.Create(&role).Error; err != nil {
t.Fatal(err)
}
if err := s.db.Create(&Assignment{TenantID: 1, UserID: "alice", RoleID: role.ID}).Error; err != nil {
t.Fatal(err)
}
router := gin.New()
router.Use(s.Middleware())
router.Handle(tc.method, "/api/v1"+tc.route, func(c *gin.Context) { c.Status(http.StatusNoContent) })
for _, admin := range []bool{false, true} {
req := httptest.NewRequest(tc.method, "/api/v1"+tc.path, nil).WithContext(versionContext(1, "alice", admin))
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
want := http.StatusForbidden
if admin {
want = http.StatusNoContent
}
if res.Code != want {
t.Fatalf("admin=%v got=%d want=%d", admin, res.Code, want)
}
}
})
}
}
func TestLegacyRolesExpandOnlyUntilSavedAsGranular(t *testing.T) {
role := Role{Permissions: types.StringArray{"content.read", "content.write", "agents.read", "agents.write", "digital-humans.read", "digital-humans.write"}}
legacy := effectiveRolePermissions(role)
for _, permission := range []string{"content.generate", "content.maps", "content.delete", "agents.skills", "agents.memory", "agents.delete", "digital-humans.distill", "digital-humans.review", "digital-humans.wechat", "digital-humans.delete"} {
if !slices.Contains(legacy, permission) {
t.Fatalf("lost legacy capability %s", permission)
}
}
for _, permission := range []string{"content.review", "content.deliver", "settings.write"} {
if slices.Contains(legacy, permission) {
t.Fatalf("unexpected permission %s", permission)
}
}
role.PermissionVersion = currentPermissionVersion
if !slices.Equal(effectiveRolePermissions(role), role.Permissions) {
t.Fatal("granular selections were broadened")
}
}
+167
View File
@@ -0,0 +1,167 @@
package accesscontrol
import (
"net/http"
"strings"
"github.com/Tencent/WeKnora/internal/accesspolicy"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
func requiredPermission(method, path, kind string, actions ...string) string {
path = strings.TrimPrefix(path, "/api/v1/")
mutating := method != http.MethodGet && method != http.MethodHead
if strings.HasPrefix(path, "workbench/resources/") && strings.HasSuffix(path, "/export") {
return "content.deliver"
}
if method != http.MethodGet && method != http.MethodHead && len(actions) > 0 &&
(strings.Contains(path, "/actions/") || strings.HasPrefix(path, "workbench/jobs/")) {
return accesspolicy.WorkbenchActionPermission(kind, actions[0])
}
module := ""
switch {
case strings.HasPrefix(path, "access/"):
return ""
case strings.HasPrefix(path, "workbench/resources/"):
if method == http.MethodDelete {
if kind == "flows" {
return "flows.delete"
}
return "content.delete"
}
if mutating && kind == "training-maps" {
return "content.maps"
}
if kind == "flows" {
module = "flows"
} else {
module = "content"
}
case strings.HasPrefix(path, "workbench/jobs/"):
if kind == "flows" {
module = "flows"
} else {
module = "content"
}
case strings.HasPrefix(path, "digital-humans"), strings.HasPrefix(path, "im/"), strings.HasPrefix(path, "im-channels"), strings.HasPrefix(path, "wechat"):
if strings.Contains(path, "/wechat/") || strings.HasPrefix(path, "wechat") || strings.HasPrefix(path, "im/") || strings.HasPrefix(path, "im-channels") {
return "digital-humans.wechat"
}
if mutating && (strings.HasSuffix(path, "/distill") || strings.HasSuffix(path, "/trial")) {
return "digital-humans.distill"
}
if mutating && strings.HasSuffix(path, "/review") {
return "digital-humans.review"
}
if method == http.MethodDelete && !strings.Contains(path, "/bindings/") {
return "digital-humans.delete"
}
module = "digital-humans"
case path == "workbench/operations/consumption":
return "knowledge.consumption"
case strings.HasPrefix(path, "workbench/operations/memories/"):
return "agents.memory"
case strings.HasPrefix(path, "workbench/operations/accounts"), strings.HasPrefix(path, "workbench/operations/grants"), strings.HasPrefix(path, "workbench/operations/quotas"):
module = "balance"
case strings.HasPrefix(path, "knowledge-chat"), strings.HasPrefix(path, "agent-chat"), strings.HasPrefix(path, "agent/"):
module = "chat"
case strings.HasPrefix(path, "knowledge"), strings.HasPrefix(path, "shared-knowledge-bases"), strings.HasPrefix(path, "chunks"), strings.HasPrefix(path, "tags"), strings.HasPrefix(path, "faq"), strings.HasPrefix(path, "initialization/config/"), strings.HasPrefix(path, "initialization/initialize/"):
module = "knowledge"
case strings.HasPrefix(path, "agents"), strings.HasPrefix(path, "shared-agents"), strings.HasPrefix(path, "embed-channels"), strings.HasPrefix(path, "custom-agents"), strings.HasPrefix(path, "skills"), strings.HasPrefix(path, "instruction-skills"), strings.HasPrefix(path, "memory"):
if mutating {
if strings.HasPrefix(path, "skills/instructions") || strings.HasPrefix(path, "instruction-skills") {
return "agents.skills"
}
if strings.HasPrefix(path, "memory") {
return "agents.memory"
}
if method == http.MethodDelete && path == "agents/:id" {
return "agents.delete"
}
}
module = "agents"
case strings.HasPrefix(path, "sessions"), strings.HasPrefix(path, "messages"), strings.HasPrefix(path, "chat"):
module = "chat"
case strings.HasPrefix(path, "organizations"):
module = "sharing"
case path == "evaluation", strings.HasPrefix(path, "tenants/kv/") && mutating, strings.HasPrefix(path, "tenants/") && (strings.Contains(path, "/members") || strings.Contains(path, "/invitations")), strings.HasPrefix(path, "me/env-vars"):
module = "settings"
case strings.HasPrefix(path, "models"), strings.HasPrefix(path, "mcp-services"), strings.HasPrefix(path, "sandbox-configs"), strings.HasPrefix(path, "vector-stores"), strings.HasPrefix(path, "storage-backends"), strings.HasPrefix(path, "web-search-providers"), strings.HasPrefix(path, "datasource"):
// Read-only selectors are used by content/Agent setup as well as settings.
if method == http.MethodGet || method == http.MethodHead {
return ""
}
module = "settings"
}
if module == "" {
return ""
}
action := "read"
if method != http.MethodGet && method != http.MethodHead && !strings.HasSuffix(path, "/search") && !strings.HasSuffix(path, "/hybrid-search") && path != "knowledge-search" {
action = "write"
}
return module + "." + action
}
func (s *Service) Middleware() gin.HandlerFunc {
return func(c *gin.Context) {
t, u := identity(c.Request.Context())
if t == 0 || u == "" || types.IsSyntheticUserID(u) {
c.Next()
return
}
path := c.FullPath()
if strings.Contains(path, "/auth/") || path == "/api/v1/access/me" {
c.Next()
return
}
snapshot, err := s.Snapshot(c.Request.Context())
if err != nil {
fail(c, 503, "权限信息暂不可用,请稍后重试")
return
}
ctx := accesspolicy.WithSnapshot(c.Request.Context(), snapshot)
c.Request = c.Request.WithContext(ctx)
kind := c.Param("kind")
action := c.Param("action")
if strings.Contains(path, "/workbench/jobs/") {
var row struct{ Kind, Action string }
if e := s.db.Table("workbench_jobs").Select("kind, action").Where("tenant_id=? AND owner_id=? AND id=?", t, u, c.Param("id")).Take(&row).Error; e == nil {
kind = row.Kind
action = row.Action
}
}
permission := requiredPermission(c.Request.Method, path, kind, action)
if strings.HasSuffix(path, "/operations/records/:recordKind") {
switch c.Param("recordKind") {
case "transactions":
permission = "balance.read"
case "usage":
permission = "usage.read"
case "audit":
permission = "audit.read"
case "system":
permission = "system.read"
case "consumption":
permission = "knowledge.read"
case "memories":
permission = "agents.read"
}
}
if permission != "" && ((!administrator(ctx) && adminOnlyPermission(permission)) || !accesspolicy.Allowed(ctx, permission)) {
label := permission
for _, m := range Catalog {
for _, p := range m.Permissions {
if p.ID == permission {
label = m.Name + " / " + p.Name
}
}
}
fail(c, 403, "没有权限:"+label)
c.Set("required_permission_label", label)
return
}
c.Next()
}
}
+134
View File
@@ -0,0 +1,134 @@
package accesscontrol
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
)
func TestFeatureRoutes(t *testing.T) {
for _, tc := range []struct{ method, path, kind, want string }{
{"GET", "/api/v1/workbench/resources/:kind", "training-maps", "content.read"},
{"POST", "/api/v1/workbench/resources/:kind/:id/actions/:action", "content-tasks", "content.write"},
{"DELETE", "/api/v1/workbench/resources/:kind/:id", "flows", "flows.delete"},
{"POST", "/api/v1/agent-chat/:session_id", "", "chat.write"},
{"POST", "/api/v1/knowledge-chat/:session_id", "", "chat.write"},
{"POST", "/api/v1/agent/tool-approvals/:pending_id", "", "chat.write"},
{"POST", "/api/v1/agent/mcp-oauth-resolutions/:pending_id", "", "chat.write"},
{"POST", "/api/v1/agent/mcp-oauth-resolutions/:pending_id/cancel", "", "chat.write"},
{"POST", "/api/v1/knowledge-search", "", "knowledge.read"},
{"GET", "/api/v1/initialization/config/:kbId", "", "knowledge.read"},
{"PUT", "/api/v1/initialization/config/:kbId", "", "knowledge.write"},
{"POST", "/api/v1/initialization/initialize/:kbId", "", "knowledge.write"},
{"POST", "/api/v1/skills/instructions/mount", "", "agents.skills"},
{"GET", "/api/v1/memory/items", "", "agents.read"},
{"GET", "/api/v1/shared-agents", "", "agents.read"},
{"GET", "/api/v1/shared-knowledge-bases", "", "knowledge.read"},
{"GET", "/api/v1/embed-channels", "", "agents.read"},
{"POST", "/api/v1/wechat/qrcode", "", "digital-humans.wechat"},
{"GET", "/api/v1/models", "", ""},
{"POST", "/api/v1/models", "", "settings.write"},
{"GET", "/api/v1/evaluation", "", "settings.read"},
{"POST", "/api/v1/evaluation", "", "settings.write"},
} {
t.Run(tc.path+tc.method, func(t *testing.T) {
if got := requiredPermission(tc.method, tc.path, tc.kind); got != tc.want {
t.Fatalf("got %q want %q", got, tc.want)
}
})
}
}
func TestMiddlewareReloadsPermissionsBeforeAlternateNativeActions(t *testing.T) {
for _, tc := range []struct {
method, route, path, permission string
}{
{http.MethodGet, "/initialization/config/:kbId", "/initialization/config/owned-kb", "knowledge.read"},
{http.MethodPut, "/initialization/config/:kbId", "/initialization/config/owned-kb", "knowledge.write"},
{http.MethodPost, "/initialization/initialize/:kbId", "/initialization/initialize/owned-kb", "knowledge.write"},
{http.MethodPost, "/agent/tool-approvals/:pending_id", "/agent/tool-approvals/owned-pending", "chat.write"},
{http.MethodPost, "/agent/mcp-oauth-resolutions/:pending_id", "/agent/mcp-oauth-resolutions/owned-pending", "chat.write"},
{http.MethodPost, "/agent/mcp-oauth-resolutions/:pending_id/cancel", "/agent/mcp-oauth-resolutions/owned-pending/cancel", "chat.write"},
} {
t.Run(tc.method+tc.route, func(t *testing.T) {
s := versionDB(t)
role := Role{ID: "native-role", TenantID: 1, Name: "native", Permissions: types.StringArray{"knowledge.read", "knowledge.write", "chat.read", "chat.write", "settings.read"}}
if err := s.db.Create(&role).Error; err != nil {
t.Fatal(err)
}
if err := s.db.Create(&Assignment{TenantID: 1, UserID: "alice", RoleID: role.ID}).Error; err != nil {
t.Fatal(err)
}
called := 0
router := gin.New()
router.Use(s.Middleware())
router.Handle(tc.method, "/api/v1"+tc.route, func(c *gin.Context) {
called++
c.Status(http.StatusNoContent)
})
request := func() int {
req := httptest.NewRequest(tc.method, "/api/v1"+tc.path, nil)
req = req.WithContext(versionContext(1, "alice", false))
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
return res.Code
}
if status := request(); status != http.StatusNoContent || called != 1 {
t.Fatalf("authorized route blocked: status=%d calls=%d", status, called)
}
retained := types.StringArray{}
for _, permission := range role.Permissions {
if permission != tc.permission {
retained = append(retained, permission)
}
}
if err := s.db.Model(&role).Update("permissions", retained).Error; err != nil {
t.Fatal(err)
}
if status := request(); status != http.StatusForbidden || called != 1 {
t.Fatalf("revoked permission reached native handler: status=%d calls=%d", status, called)
}
})
}
}
func TestPermissionValidation(t *testing.T) {
if validPermissions([]string{"content.write"}) {
t.Fatal("write without read accepted")
}
if validPermissions([]string{"*"}) {
t.Fatal("unknown wildcard accepted")
}
if !validPermissions([]string{"content.read", "content.write"}) {
t.Fatal("valid feature permissions rejected")
}
if !validPermissions(nil) {
t.Fatal("deny-all role rejected")
}
}
func TestContentActionPermissions(t *testing.T) {
for _, action := range []string{"accept", "skip", "review", "review-settings", "compose"} {
for _, path := range []string{"/api/v1/workbench/resources/:kind/:id/actions/:action", "/api/v1/workbench/jobs/:id/:action"} {
if got := requiredPermission("POST", path, "content-tasks", action); got != "content.review" {
t.Fatalf("%s %s: %s", path, action, got)
}
}
}
for _, action := range []string{"generate", "revise", "decompose", "import-image"} {
if got := requiredPermission("POST", "/api/v1/workbench/jobs/:id/:action", "content-tasks", action); got != "content.generate" {
t.Fatalf("production action %s: %s", action, got)
}
}
if got := requiredPermission("GET", "/api/v1/workbench/resources/:kind/:id/export", "content-tasks"); got != "content.deliver" {
t.Fatalf("export permission: %s", got)
}
for _, permission := range []string{"content.review", "content.deliver"} {
if validPermissions([]string{permission}) || !validPermissions([]string{"content.read", permission}) {
t.Fatalf("read prerequisite for %s", permission)
}
}
}
+137
View File
@@ -0,0 +1,137 @@
package accesscontrol
import (
"strings"
"time"
"github.com/Tencent/WeKnora/internal/types"
)
type Role struct {
ID string `json:"id" gorm:"primaryKey"`
TenantID uint64 `json:"tenant_id"`
Name string `json:"name"`
Description string `json:"description"`
Permissions types.StringArray `json:"permissions" gorm:"type:jsonb"`
Version int `json:"version"`
PermissionVersion int `json:"permission_version" gorm:"not null;default:0"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Role) TableName() string { return "workbench_access_roles" }
type Assignment struct {
TenantID uint64 `gorm:"primaryKey"`
UserID string `gorm:"primaryKey"`
RoleID string
}
func (Assignment) TableName() string { return "workbench_access_assignments" }
type Publication struct {
TenantID uint64 `json:"tenant_id" gorm:"primaryKey"`
Kind string `json:"kind" gorm:"primaryKey"`
ResourceID string `json:"resource_id" gorm:"primaryKey"`
Global bool `json:"global"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Publication) TableName() string { return "workbench_access_publications" }
type Grant struct {
TenantID uint64 `json:"tenant_id" gorm:"primaryKey"`
Kind string `json:"kind" gorm:"primaryKey"`
ResourceID string `json:"resource_id" gorm:"primaryKey"`
UserID string `json:"user_id" gorm:"primaryKey"`
UpdatedAt time.Time `json:"updated_at"`
}
func (Grant) TableName() string { return "workbench_access_grants" }
type Permission struct {
ID string `json:"id"`
Name string `json:"name"`
}
type Module struct {
ID string `json:"id"`
Name string `json:"name"`
Permissions []Permission `json:"permissions"`
AdminOnly bool `json:"admin_only,omitempty"`
}
var Catalog = []Module{
{"chat", "对话", []Permission{{"chat.read", "查看对话"}, {"chat.write", "发起对话与生成"}}, false},
{"content", "内容任务", []Permission{{"content.read", "查看内容任务与训练营导图"}, {"content.write", "创建与编辑内容任务"}, {"content.generate", "拆解、生成与修改内容"}, {"content.maps", "创建、生成与修改训练营导图"}, {"content.review", "采用、跳过与跨稿质检"}, {"content.delete", "删除内容任务与训练营导图"}, {"content.deliver", "复制与下载成稿"}}, false},
{"flows", "画布编排", []Permission{{"flows.read", "查看画布"}, {"flows.write", "创建与修改画布"}, {"flows.delete", "删除画布"}}, false},
{"digital-humans", "数字人", []Permission{{"digital-humans.read", "查看数字人与蒸馏结果"}, {"digital-humans.write", "创建、编辑与绑定 Agent"}, {"digital-humans.distill", "提交资料、蒸馏与试写"}, {"digital-humans.review", "审核蒸馏版本"}, {"digital-humans.wechat", "连接与管理微信账号"}, {"digital-humans.delete", "删除数字人"}}, false},
{"knowledge", "知识库", []Permission{{"knowledge.read", "查看、检索、Wiki 与知识图谱"}, {"knowledge.write", "上传、分类、编辑与删除知识"}, {"knowledge.consumption", "开放与禁用自己的消费记录"}}, false},
{"agents", "Agent 与技能", []Permission{{"agents.read", "查看 Agent、技能与记忆"}, {"agents.write", "手动、AI 创建与配置 Agent"}, {"agents.skills", "导入、挂载与删除 Markdown 技能"}, {"agents.memory", "编辑、审核与整理记忆"}, {"agents.delete", "删除 Agent"}}, false},
{"sharing", "共享空间", []Permission{{"sharing.read", "查看共享空间"}, {"sharing.write", "管理共享(仍受空间管理员限制)"}}, false},
{"settings", "设置", []Permission{{"settings.read", "查看空间设置"}, {"settings.write", "修改空间设置"}}, true},
{"access", "访问控制", []Permission{{"access.read", "查看用户、角色与资源授权"}, {"access.write", "管理用户、角色与资源授权"}}, true},
{"balance", "余额", []Permission{{"balance.read", "查看全部用户余额与发放记录"}, {"balance.write", "发放余额与设置额度"}}, true},
{"usage", "用量分析", []Permission{{"usage.read", "查看模型用量与费用明细"}}, true},
{"audit", "操作日志", []Permission{{"audit.read", "查看用户操作记录"}}, true},
{"system", "系统错误日志", []Permission{{"system.read", "查看运行错误与 AI 错误"}}, true},
}
const currentPermissionVersion = 2
// Legacy roles used one write permission for all actions in a module.
// Expand only unsaved legacy rows so deselecting a new permission is effective.
func effectiveRolePermissions(role Role) types.StringArray {
permissions := append(types.StringArray{}, role.Permissions...)
if role.PermissionVersion >= currentPermissionVersion {
return permissions
}
selected := map[string]bool{}
for _, permission := range permissions {
selected[permission] = true
}
for _, module := range Catalog {
if !selected[module.ID+".read"] || !selected[module.ID+".write"] || module.AdminOnly {
continue
}
for _, permission := range module.Permissions {
if selected[permission.ID] || permission.ID == "content.review" || permission.ID == "content.deliver" {
continue
}
permissions = append(permissions, permission.ID)
selected[permission.ID] = true
}
}
return permissions
}
func adminOnlyPermission(permission string) bool {
for _, module := range Catalog {
if strings.HasPrefix(permission, module.ID+".") {
return module.AdminOnly
}
}
return false
}
func validPermissions(values []string) bool {
known := map[string]bool{}
for _, m := range Catalog {
for _, p := range m.Permissions {
known[p.ID] = true
}
}
selected := map[string]bool{}
for _, v := range values {
if !known[v] {
return false
}
selected[v] = true
}
for _, value := range values {
module, _, _ := strings.Cut(value, ".")
if !strings.HasSuffix(value, ".read") && !selected[module+".read"] {
return false
}
}
return true
}
@@ -0,0 +1,142 @@
package accesscontrol
import (
"context"
"fmt"
"net/http"
"net/http/httptest"
"os"
"os/exec"
"path/filepath"
"strings"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
"gorm.io/driver/postgres"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
)
func TestRolePermissionVersionMigrationPreservesLegacyAndAllowsGranularSave(t *testing.T) {
for _, dialect := range []string{"sqlite", "postgres"} {
t.Run(dialect, func(t *testing.T) {
db := permissionMigrationDB(t, dialect)
migrationDir := "sqlite"
if dialect == "postgres" {
migrationDir = "versioned"
applyPermissionMigration(t, db, migrationDir, "000196_workbench_access.up.sql")
} else {
require.NoError(t, db.Exec(`CREATE TABLE workbench_access_roles (
id TEXT PRIMARY KEY, tenant_id INTEGER NOT NULL, name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '', permissions TEXT NOT NULL DEFAULT '[]',
version INTEGER NOT NULL DEFAULT 1, created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, UNIQUE (tenant_id, name))`).Error)
}
require.False(t, db.Migrator().HasColumn(&Role{}, "permission_version"))
require.NoError(t, db.Exec(`INSERT INTO workbench_access_roles
(id, tenant_id, name, permissions, version) VALUES (?, ?, ?, ?, ?)`,
"legacy", 1, "legacy", types.StringArray{"agents.read", "agents.write"}, 7).Error)
applyPermissionMigration(t, db, migrationDir, "000215_access_role_permission_version.up.sql")
var legacy Role
require.NoError(t, db.First(&legacy, "id = ?", "legacy").Error)
require.Equal(t, 0, legacy.PermissionVersion)
require.Equal(t, 7, legacy.Version)
require.Equal(t, types.StringArray{"agents.read", "agents.write"}, legacy.Permissions)
require.Contains(t, effectiveRolePermissions(legacy), "agents.skills")
require.Contains(t, effectiveRolePermissions(legacy), "agents.memory")
// Only unrelated audit infrastructure uses AutoMigrate: the role schema
// must come from the shipped SQL to catch production upgrade omissions.
require.NoError(t, db.AutoMigrate(&types.AuditLog{}))
s := NewService(db)
router := gin.New()
s.Register(router.Group("/api/v1"))
for _, action := range []struct{ method, path, body string }{
{"POST", "/api/v1/access/roles", `{"name":"new-granular","permissions":["agents.read","agents.write"]}`},
{"PUT", "/api/v1/access/roles/legacy", `{"name":"legacy","permissions":["agents.read","agents.write"],"version":7}`},
} {
req := httptest.NewRequest(action.method, action.path, strings.NewReader(action.body)).WithContext(versionContext(1, "admin", true))
req.Header.Set("Content-Type", "application/json")
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
require.Equal(t, http.StatusOK, res.Code, res.Body.String())
}
var roles []Role
require.NoError(t, db.Order("name").Find(&roles).Error)
require.Len(t, roles, 2)
for _, role := range roles {
require.Equal(t, currentPermissionVersion, role.PermissionVersion)
require.NotContains(t, effectiveRolePermissions(role), "agents.skills")
require.NotContains(t, effectiveRolePermissions(role), "agents.memory")
}
if dialect == "postgres" {
// The production hotfix can precede the next deployment; rerunning
// migration 215 must not reset already saved granular selections.
applyPermissionMigration(t, db, migrationDir, "000215_access_role_permission_version.up.sql")
require.NoError(t, db.First(&legacy, "id = ?", "legacy").Error)
require.Equal(t, currentPermissionVersion, legacy.PermissionVersion)
require.Equal(t, 8, legacy.Version)
}
applyPermissionMigration(t, db, migrationDir, "000215_access_role_permission_version.down.sql")
require.False(t, db.Migrator().HasColumn(&Role{}, "permission_version"))
var count int64
require.NoError(t, db.Table("workbench_access_roles").Count(&count).Error)
require.EqualValues(t, 2, count)
})
}
}
func applyPermissionMigration(t *testing.T, db *gorm.DB, dialect, name string) {
t.Helper()
sql, err := os.ReadFile(filepath.Join("..", "..", "migrations", dialect, name))
require.NoError(t, err)
require.NoError(t, db.Exec(string(sql)).Error)
}
func permissionMigrationDB(t *testing.T, dialect string) *gorm.DB {
t.Helper()
var dialector gorm.Dialector
if dialect == "sqlite" {
dialector = sqlite.Open(filepath.Join(t.TempDir(), "roles.db"))
} else {
bin := os.Getenv("POSTGRES_TEST_BIN")
if bin == "" {
bin = "/opt/homebrew/opt/postgresql@16/bin"
}
if _, err := os.Stat(filepath.Join(bin, "initdb")); err != nil {
t.Skip("isolated PostgreSQL binaries unavailable; set POSTGRES_TEST_BIN")
}
dir, err := os.MkdirTemp("/tmp", "role-migration-pg-")
require.NoError(t, err)
run := func(name string, args ...string) {
t.Helper()
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
output, err := exec.CommandContext(ctx, filepath.Join(bin, name), args...).CombinedOutput()
require.NoError(t, err, "%s: %s", name, output)
}
data := filepath.Join(dir, "data")
running := false
t.Cleanup(func() {
if running {
run("pg_ctl", "-D", data, "-m", "fast", "-w", "stop")
}
require.NoError(t, os.RemoveAll(dir))
})
run("initdb", "-D", data, "-U", "fixture", "--auth=trust", "--no-locale", "--encoding=UTF8")
run("pg_ctl", "-D", data, "-l", filepath.Join(dir, "postgres.log"), "-o",
fmt.Sprintf("-F -c listen_addresses='' -k %s -p 55461", dir), "-w", "start")
running = true
dialector = postgres.Open(fmt.Sprintf("host=%s port=55461 user=fixture dbname=postgres sslmode=disable", dir))
}
db, err := gorm.Open(dialector, &gorm.Config{})
require.NoError(t, err)
sqlDB, err := db.DB()
require.NoError(t, err)
t.Cleanup(func() { require.NoError(t, sqlDB.Close()) })
return db
}
@@ -0,0 +1,48 @@
package accesscontrol
import (
"context"
"github.com/Tencent/WeKnora/internal/accesspolicy"
"github.com/Tencent/WeKnora/internal/types"
)
func (s *Service) populatePersonalAccess(ctx context.Context, snapshot *accesspolicy.Snapshot) error {
t, u := identity(ctx)
snapshot.Hidden = map[string]bool{}
snapshot.NoExport = map[string]bool{}
if !administrator(ctx) {
var rows []ResourceHidden
if err := s.db.WithContext(ctx).Where("tenant_id=? AND owner_id=?", t, u).Find(&rows).Error; err != nil {
return err
}
for _, row := range rows {
snapshot.Hidden[row.Kind+":"+row.ResourceID] = true
}
humanIDs := []string{}
for _, row := range rows {
if row.Kind == "digital-humans" {
humanIDs = append(humanIDs, row.ResourceID)
}
}
if len(humanIDs) > 0 {
var humans []types.DigitalHuman
if err := s.db.WithContext(ctx).Select("primary_agent_id").Where("tenant_id=? AND id IN ?", t, humanIDs).Find(&humans).Error; err != nil {
return err
}
for _, human := range humans {
if human.PrimaryAgentID != "" {
snapshot.Hidden["agents:"+human.PrimaryAgentID] = true
}
}
}
}
var forks []ResourceFork
if err := s.db.WithContext(ctx).Where("tenant_id=? AND no_export=true", t).Find(&forks).Error; err != nil {
return err
}
for _, row := range forks {
snapshot.NoExport[row.Kind+":"+row.PersonalID] = true
}
return nil
}
@@ -0,0 +1,677 @@
package accesscontrol
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/accesspolicy"
"github.com/Tencent/WeKnora/internal/knowledgeversion"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type KnowledgeVersionJob struct {
ID string `json:"id" gorm:"primaryKey"`
TenantID uint64 `json:"tenant_id"`
OwnerID string `json:"owner_id"`
ForkID string `json:"fork_id"`
SubmissionID string `json:"submission_id,omitempty"`
Purpose string `json:"purpose"`
SourceID string `json:"source_id"`
TargetID string `json:"target_id"`
ExpectedHash string `json:"-"`
Status string `json:"status"`
Processed int `json:"processed"`
Total int `json:"total"`
Attempts int `json:"attempts"`
Error string `json:"error,omitempty"`
LeaseToken string `json:"-"`
LeaseUntil *time.Time `json:"-"`
AbandonedIDs types.StringArray `json:"-" gorm:"type:jsonb"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (KnowledgeVersionJob) TableName() string { return "workbench_knowledge_version_jobs" }
// Aliases affect new personal branches only. Already bound KB IDs stay pinned.
type KnowledgeVersionAlias struct {
TenantID uint64 `gorm:"primaryKey"`
SourceID string `gorm:"primaryKey"`
CurrentID string
UpdatedAt time.Time
}
func (KnowledgeVersionAlias) TableName() string { return "workbench_knowledge_version_aliases" }
func resolveKnowledgeVersion(tx *gorm.DB, tenant uint64, id string) (string, error) {
var alias KnowledgeVersionAlias
err := tx.Where("tenant_id=? AND source_id=?", tenant, id).First(&alias).Error
if errors.Is(err, gorm.ErrRecordNotFound) {
return id, nil
}
if err != nil {
return "", err
}
return alias.CurrentID, nil
}
// SetKnowledgeVersionServices accepts the native service without expanding its
// broad public interface. The optional capability is implemented in a separate
// native service file so mocks do not claim to copy external objects.
func (s *Service) SetKnowledgeVersionServices(service any) error {
adapter, ok := service.(KnowledgeVersionAdapter)
if !ok {
return fmt.Errorf("native knowledge service cannot clone immutable versions")
}
s.versionKnowledge = adapter
return nil
}
type knowledgeVersionDocument struct {
Title string `json:"title"`
Type string `json:"type"`
Folder string `json:"folder"`
FileHash string `json:"file_hash"`
Chunks int `json:"chunks"`
Fingerprint string `json:"fingerprint"`
}
type knowledgeVersionView struct {
GraphFingerprint string `json:"graph_fingerprint,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
Type string `json:"type"`
Configuration map[string]any `json:"configuration"`
Documents []knowledgeVersionDocument `json:"documents"`
Tags []types.KnowledgeTag `json:"categories"`
}
func knowledgeVersionSnapshot(ctx context.Context, tx *gorm.DB, id string) (*knowledgeVersionView, error) {
t, _ := identity(ctx)
var kb types.KnowledgeBase
if err := tx.Where("tenant_id=? AND id=?", t, id).First(&kb).Error; err != nil {
return nil, err
}
if kb.VersionState != "" && !knowledgeversion.IsWorker(ctx) {
return nil, fmt.Errorf("%w: 知识库版本仍在准备中", errConflict)
}
// Explicit fields prevent legacy storage credentials from entering API or
// immutable review payloads. File paths are hashed, never returned.
vlm := kb.VLMConfig
vlm.APIKey, vlm.BaseURL = "", ""
out := &knowledgeVersionView{Name: kb.Name, Description: kb.Description, Type: kb.Type, Documents: []knowledgeVersionDocument{}, Configuration: map[string]any{
"chunking": kb.ChunkingConfig, "image_processing": kb.ImageProcessingConfig,
"embedding_model_id": kb.EmbeddingModelID, "summary_model_id": kb.SummaryModelID,
"vector_store_id": kb.VectorStoreID, "storage_backend_id": kb.StorageBackendID,
"faq": kb.FAQConfig, "indexing": kb.IndexingStrategy,
"vlm": vlm, "asr": kb.ASRConfig, "extraction": kb.ExtractConfig,
"questions": kb.QuestionGenerationConfig, "automatic_categories": kb.AutoTagConfig,
}}
if err := tx.Where("tenant_id=? AND knowledge_base_id=?", t, id).Order("id").Find(&out.Tags).Error; err != nil {
return nil, err
}
var docs []types.Knowledge
if err := tx.Where("tenant_id=? AND knowledge_base_id=?", t, id).Order("id").Find(&docs).Error; err != nil {
return nil, err
}
for _, doc := range docs {
if doc.ParseStatus != types.ParseStatusCompleted || doc.PendingSubtasksCount > 0 {
return nil, fmt.Errorf("%w: 知识 %s 尚未完成索引", errConflict, doc.Title)
}
h := sha256.New()
if err := json.NewEncoder(h).Encode(doc); err != nil {
return nil, err
}
var relations []types.KnowledgeTagRelation
if err := tx.Where("knowledge_id=?", doc.ID).Order("tag_id").Find(&relations).Error; err != nil {
return nil, err
}
if err := json.NewEncoder(h).Encode(relations); err != nil {
return nil, err
}
rows, err := tx.Model(&types.Chunk{}).Where("tenant_id=? AND knowledge_id=?", t, doc.ID).Order("id").Rows()
if err != nil {
return nil, err
}
count := 0
for rows.Next() {
var chunk types.Chunk
if err := tx.ScanRows(rows, &chunk); err != nil {
rows.Close()
return nil, err
}
if chunk.IndexStatus == "processing" || chunk.IndexStatus == "failed" {
rows.Close()
return nil, fmt.Errorf("%w: 知识 %s 的片段索引未就绪", errConflict, doc.Title)
}
if err := json.NewEncoder(h).Encode(chunk); err != nil {
rows.Close()
return nil, err
}
if err := json.NewEncoder(h).Encode([]string{chunk.SourceContent, chunk.ContextHeader}); err != nil {
rows.Close()
return nil, err
}
count++
}
rowErr := rows.Err()
rows.Close()
if rowErr != nil {
return nil, rowErr
}
out.Documents = append(out.Documents, knowledgeVersionDocument{Title: doc.Title, Type: doc.Type, Folder: doc.FolderPath, FileHash: doc.FileHash, Chunks: count, Fingerprint: hex.EncodeToString(h.Sum(nil))})
}
var graphErr error
out.GraphFingerprint, graphErr = knowledgeversion.GraphFingerprint(tx, t, id)
if graphErr != nil {
return nil, graphErr
}
return out, nil
}
func newKnowledgeVersionBase(tx *gorm.DB, tenant uint64, owner, source, target string) error {
var kb types.KnowledgeBase
if err := tx.Where("tenant_id=? AND id=?", tenant, source).First(&kb).Error; err != nil {
return err
}
if kb.Type == types.KnowledgeBaseTypeWiki || kb.IsWikiEnabled() || kb.IsGraphEnabled() {
return fmt.Errorf("%w: Wiki 或图谱知识库暂不支持独立个人版本,请使用文档或问答知识库", errInvalid)
}
var unsupported int64
if err := tx.Model(&types.Chunk{}).Where("tenant_id=? AND knowledge_base_id=? AND chunk_type NOT IN ?", tenant, source,
[]types.ChunkType{types.ChunkTypeText, types.ChunkTypeParentText, types.ChunkTypeSummary, types.ChunkTypeImageCaption, types.ChunkTypeImageOCR, types.ChunkTypeFAQ}).Count(&unsupported).Error; err != nil {
return err
}
if unsupported > 0 {
return fmt.Errorf("%w: 此知识库包含暂不支持独立复制的表格、图谱或 Wiki 片段", errInvalid)
}
kb.ID, kb.CreatorID, kb.VersionState = target, owner, "copying"
kb.IsTemporary, kb.IsPinned, kb.PinnedAt = true, false, nil
kb.CreatedAt, kb.UpdatedAt = time.Time{}, time.Time{}
kb.DeletedAt = gorm.DeletedAt{}
if err := tx.Create(&kb).Error; err != nil {
return err
}
return tx.Create(&Publication{TenantID: tenant, Kind: "knowledge", ResourceID: target, Global: false}).Error
}
func (s *Service) reserveKnowledgeFork(tx *gorm.DB, ctx context.Context, source ResourceView, out *ResourceFork) error {
if s.versionKnowledge == nil {
return fmt.Errorf("%w: 知识库复制服务未启用", errInvalid)
}
t, u := identity(ctx)
body, hash, err := s.resourceSnapshot(tx, ctx, "knowledge", source.ID)
if err != nil {
return err
}
*out = ResourceFork{ID: uuid.NewString(), TenantID: t, OwnerID: u, Kind: "knowledge", SourceID: source.ID, PersonalID: uuid.NewString(), Name: source.Name, BaseFingerprint: hash, BaseSnapshot: body, NoExport: true, Status: "copying"}
if err := newKnowledgeVersionBase(tx, t, u, source.ID, out.PersonalID); err != nil {
return err
}
if err := tx.Create(out).Error; err != nil {
return err
}
job := KnowledgeVersionJob{ID: uuid.NewString(), TenantID: t, OwnerID: u, ForkID: out.ID, Purpose: "fork", SourceID: source.ID, TargetID: out.PersonalID, ExpectedHash: hash, Status: "queued", AbandonedIDs: types.StringArray{}}
if err := tx.Create(&job).Error; err != nil {
return err
}
return s.audit(tx, ctx, "resource.knowledge_copy_queued", out.ID, map[string]any{"job_id": job.ID, "source_id": source.ID})
}
func (s *Service) reserveKnowledgeSubmission(tx *gorm.DB, ctx context.Context, f ResourceFork, note string, out *ResourceSubmission) error {
if s.versionKnowledge == nil || f.Status != "ready" {
return fmt.Errorf("%w: 请等待个人知识库复制完成", errConflict)
}
t, u := identity(ctx)
source, err := sourceView(tx, t, "knowledge", f.SourceID)
if err != nil {
return err
}
if !source.Global {
return errVersionDenied
}
personal, err := sourceView(tx, t, "knowledge", f.PersonalID)
if err != nil {
return err
}
if personal.OwnerID != u {
return errVersionDenied
}
var count int64
if err := tx.Model(&ResourceSubmission{}).Where("tenant_id=? AND fork_id=? AND status IN ?", t, f.ID, []string{"pending", "snapshotting"}).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return fmt.Errorf("%w: 已有正在准备或待审核的版本", errConflict)
}
body, hash, err := s.resourceSnapshot(tx, ctx, "knowledge", f.PersonalID)
if err != nil {
return err
}
*out = ResourceSubmission{ID: uuid.NewString(), TenantID: t, OwnerID: u, ForkID: f.ID, Kind: "knowledge", SourceID: f.SourceID, PersonalID: f.PersonalID, Name: personal.Name, Status: "snapshotting", BaseFingerprint: f.BaseFingerprint, PersonalFingerprint: hash, BaseSnapshot: f.BaseSnapshot, ProposedSnapshot: body, Note: strings.TrimSpace(note), SnapshotID: uuid.NewString()}
if err := newKnowledgeVersionBase(tx, t, u, f.PersonalID, out.SnapshotID); err != nil {
return err
}
if err := tx.Create(out).Error; err != nil {
return err
}
job := KnowledgeVersionJob{ID: uuid.NewString(), TenantID: t, OwnerID: u, ForkID: f.ID, SubmissionID: out.ID, Purpose: "submission", SourceID: f.PersonalID, TargetID: out.SnapshotID, ExpectedHash: hash, Status: "queued", AbandonedIDs: types.StringArray{}}
if err := tx.Create(&job).Error; err != nil {
return err
}
return s.audit(tx, ctx, "resource.knowledge_snapshot_queued", out.ID, map[string]any{"job_id": job.ID})
}
// Publishing promotes an already complete, independently owned physical KB.
// The former global KB remains an archived revision for existing pinned IDs;
// no files or external indexes are overwritten in a database transaction.
func (s *Service) reviewKnowledgeSubmission(tx *gorm.DB, ctx context.Context, r *ResourceSubmission, approve bool, note string) error {
t, u := identity(ctx)
if err := versionPermission(ctx, "knowledge", true); err != nil {
return err
}
if approve {
var source types.KnowledgeBase
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND id=? AND is_temporary=false", t, r.SourceID).First(&source).Error; err != nil {
return err
}
v, err := sourceView(tx, t, "knowledge", r.SourceID)
if err != nil {
return err
}
if !v.Global {
return errVersionDenied
}
_, hash, err := s.resourceSnapshot(tx, ctx, "knowledge", r.SourceID)
if err != nil {
return err
}
if hash != r.BaseFingerprint {
return fmt.Errorf("%w: 全局知识库已有变化,请同步审核基准后重新提交", errConflict)
}
var stage types.KnowledgeBase
if err := tx.Where("tenant_id=? AND id=? AND version_state='snapshot'", t, r.SnapshotID).First(&stage).Error; err != nil {
return err
}
stage.CreatorID, stage.VersionState, stage.IsTemporary = source.CreatorID, "", false
if err := tx.Save(&stage).Error; err != nil {
return err
}
if err := tx.Model(&types.KnowledgeBase{}).Where("tenant_id=? AND id=?", t, source.ID).Update("is_temporary", true).Error; err != nil {
return err
}
if err := tx.Model(&Publication{}).Where("tenant_id=? AND kind='knowledge' AND resource_id=?", t, stage.ID).Update("global", true).Error; err != nil {
return err
}
if err := tx.Model(&KnowledgeVersionAlias{}).Where("tenant_id=? AND current_id=?", t, source.ID).Update("current_id", stage.ID).Error; err != nil {
return err
}
if err := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "tenant_id"}, {Name: "source_id"}}, DoUpdates: clause.AssignmentColumns([]string{"current_id", "updated_at"})}).Create(&KnowledgeVersionAlias{TenantID: t, SourceID: source.ID, CurrentID: stage.ID, UpdatedAt: time.Now()}).Error; err != nil {
return err
}
var hidden []ResourceHidden
if err := tx.Where("tenant_id=? AND kind='knowledge' AND resource_id=?", t, source.ID).Find(&hidden).Error; err != nil {
return err
}
for _, row := range hidden {
row.ResourceID = stage.ID
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error; err != nil {
return err
}
}
// Existing personal branches follow the current review target while
// retaining their old baseline, making conflicting approval explicit.
if err := tx.Model(&ResourceFork{}).Where("tenant_id=? AND kind='knowledge' AND source_id=?", t, source.ID).Update("source_id", stage.ID).Error; err != nil {
return err
}
body, newHash, err := s.resourceSnapshot(tx, ctx, "knowledge", stage.ID)
if err != nil {
return err
}
if err := tx.Model(&ResourceFork{}).Where("tenant_id=? AND id=?", t, r.ForkID).Updates(map[string]any{"base_snapshot": body, "base_fingerprint": newHash}).Error; err != nil {
return err
}
r.PublishedID, r.Status = stage.ID, "approved"
} else {
r.Status = "rejected"
}
now := time.Now()
r.ReviewedAt = &now
r.ReviewerID = u
r.ReviewNote = strings.TrimSpace(note)
if err := tx.Save(r).Error; err != nil {
return err
}
return s.audit(tx, ctx, "resource.knowledge_version_"+r.Status, r.ID, map[string]any{"source_id": r.SourceID, "published_id": r.PublishedID})
}
func (s *Service) RetryKnowledgeVersionJob(c *gin.Context) {
versionResult(c, s.retryKnowledgeVersionJob(c.Request.Context(), c.Param("id")), true)
}
func (s *Service) retryKnowledgeVersionJob(ctx context.Context, jobID string) error {
t, u := identity(ctx)
if err := versionPermission(ctx, "knowledge", true); err != nil {
return err
}
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var j KnowledgeVersionJob
q := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND id=?", t, jobID)
if !administrator(ctx) {
q = q.Where("owner_id=?", u)
}
if err := q.First(&j).Error; err != nil {
return err
}
if j.Status != "failed" {
return errConflict
}
if j.Purpose == "fork" {
id, err := resolveKnowledgeVersion(tx, t, j.SourceID)
if err != nil {
return err
}
j.SourceID = id
}
body, hash, err := s.resourceSnapshot(tx, ctx, "knowledge", j.SourceID)
if err != nil {
return err
}
if hash != j.ExpectedHash && j.Purpose != "fork" {
return fmt.Errorf("%w: 原资料已变更,请重新提交版本", errConflict)
}
if j.Purpose == "submission" {
var n int64
if err := tx.Model(&ResourceSubmission{}).Where("tenant_id=? AND fork_id=? AND id<>? AND status IN ?", t, j.ForkID, j.SubmissionID, []string{"pending", "snapshotting"}).Count(&n).Error; err != nil {
return err
}
if n > 0 {
return errConflict
}
r := tx.Model(&ResourceSubmission{}).Where("tenant_id=? AND id=? AND status='snapshot_failed'", t, j.SubmissionID).Update("status", "snapshotting")
if r.Error != nil {
return r.Error
}
if r.RowsAffected != 1 {
return errConflict
}
} else {
if err := tx.Model(&ResourceFork{}).Where("tenant_id=? AND id=?", t, j.ForkID).Updates(map[string]any{"status": "copying", "source_id": j.SourceID, "base_snapshot": body, "base_fingerprint": hash}).Error; err != nil {
return err
}
}
return tx.Model(&j).Updates(map[string]any{"status": "queued", "error": "", "lease_until": nil, "source_id": j.SourceID, "expected_hash": hash}).Error
})
}
// Resource versions are retired. Keep queued records and historical data intact
// without starting copies or cleanup from an older installation.
func (s *Service) StartKnowledgeVersionWorker(context.Context) {}
// Persisted copy jobs must not retain permissions revoked after enqueueing.
func (s *Service) authorizeKnowledgeVersionJob(ctx context.Context, j KnowledgeVersionJob) (context.Context, error) {
var user types.User
if err := s.db.WithContext(ctx).Select("id", "tenant_id", "is_active", "is_system_admin", "can_access_all_tenants").Where("id=? AND is_active=?", j.OwnerID, true).First(&user).Error; err != nil {
return ctx, fmt.Errorf("%w: 复制任务所属账号已不可用", errVersionDenied)
}
var member types.TenantMember
if err := s.db.WithContext(ctx).Where("tenant_id=? AND user_id=? AND status=?", j.TenantID, j.OwnerID, types.TenantMemberStatusActive).First(&member).Error; err != nil {
if !errors.Is(err, gorm.ErrRecordNotFound) || !user.CanAccessAllTenants || j.TenantID == user.TenantID {
return ctx, fmt.Errorf("%w: 复制任务所属空间权限已失效", errVersionDenied)
}
// Match request authentication without creating a persistent membership.
member.Role = types.TenantRoleAdmin
}
ctx = context.WithValue(ctx, types.TenantIDContextKey, j.TenantID)
ctx = context.WithValue(ctx, types.UserIDContextKey, j.OwnerID)
ctx = context.WithValue(ctx, types.TenantRoleContextKey, member.Role)
ctx = context.WithValue(ctx, types.SystemAdminContextKey, user.IsSystemAdmin)
snapshot, err := s.Snapshot(ctx)
if err != nil {
return ctx, err
}
ctx = accesspolicy.WithSnapshot(ctx, snapshot)
if err := versionPermission(ctx, "knowledge", true); err != nil {
return ctx, err
}
if err := versionPermission(ctx, "knowledge", false); err != nil {
return ctx, err
}
source, err := sourceView(s.db.WithContext(ctx), j.TenantID, "knowledge", j.SourceID)
if err != nil {
return ctx, err
}
if accesspolicy.Hidden(ctx, "knowledge", j.SourceID) {
return ctx, errVersionDenied
}
switch j.Purpose {
case "fork":
if !source.Global {
return ctx, fmt.Errorf("%w: 原知识库已取消全局发布", errVersionDenied)
}
case "submission":
var fork ResourceFork
if err := s.db.WithContext(ctx).Where("id=? AND tenant_id=? AND owner_id=? AND personal_id=?", j.ForkID, j.TenantID, j.OwnerID, j.SourceID).First(&fork).Error; err != nil {
return ctx, errVersionDenied
}
original, err := sourceView(s.db.WithContext(ctx), j.TenantID, "knowledge", fork.SourceID)
if err != nil || !original.Global || source.OwnerID != j.OwnerID {
return ctx, errVersionDenied
}
default:
return ctx, errVersionDenied
}
return ctx, nil
}
// RunKnowledgeVersionJob claims one persisted job. Lease takeover always uses
// a new target, so a delayed worker can only write its abandoned private KB.
func (s *Service) RunKnowledgeVersionJob(ctx context.Context) error {
if s.versionKnowledge == nil {
return errInvalid
}
var j KnowledgeVersionJob
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
now := time.Now()
claim := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).Where("status='queued' OR (status='running' AND lease_until < ?)", now).Order("created_at").Limit(1).Find(&j)
if claim.Error != nil {
return claim.Error
}
if claim.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
if j.Attempts > 0 {
target := uuid.NewString()
if err := newKnowledgeVersionBase(tx, j.TenantID, j.OwnerID, j.SourceID, target); err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) || errors.Is(err, errInvalid) {
return s.failKnowledgeVersionJob(tx, &j, err)
}
return err
}
j.AbandonedIDs = append(j.AbandonedIDs, j.TargetID)
j.TargetID = target
if j.Purpose == "fork" {
if err := tx.Model(&ResourceFork{}).Where("id=? AND tenant_id=?", j.ForkID, j.TenantID).Update("personal_id", j.TargetID).Error; err != nil {
return err
}
} else {
if err := tx.Model(&ResourceSubmission{}).Where("id=? AND tenant_id=?", j.SubmissionID, j.TenantID).Update("snapshot_id", j.TargetID).Error; err != nil {
return err
}
}
}
until := now.Add(time.Minute)
j.LeaseUntil = &until
j.LeaseToken = uuid.NewString()
j.Status = "running"
j.Attempts++
j.Processed = 0
j.Error = ""
return tx.Save(&j).Error
})
if err != nil {
return err
}
if j.Status == "failed" {
return nil
}
worker, cancel := context.WithTimeout(knowledgeversion.WithWorker(ctx), 2*time.Hour)
defer cancel()
worker, err = s.authorizeKnowledgeVersionJob(worker, j)
if err != nil {
return s.finishKnowledgeVersionJob(ctx, j, err)
}
done := make(chan struct{})
defer close(done)
go func() {
tick := time.NewTicker(15 * time.Second)
defer tick.Stop()
for {
select {
case <-done:
return
case <-worker.Done():
return
case <-tick.C:
r := s.db.WithContext(worker).Model(&KnowledgeVersionJob{}).Where("id=? AND lease_token=? AND status='running'", j.ID, j.LeaseToken).Update("lease_until", time.Now().Add(time.Minute))
if r.Error != nil || r.RowsAffected != 1 {
cancel()
return
}
}
}
}()
_, hash, err := s.resourceSnapshot(s.db.WithContext(worker), worker, "knowledge", j.SourceID)
if err == nil && hash != j.ExpectedHash {
err = fmt.Errorf("原资料已变更,请重新提交版本")
}
if err == nil {
err = s.versionKnowledge.CloneKnowledgeVersion(worker, j.SourceID, j.TargetID, func(n, total int) error {
if _, err := s.authorizeKnowledgeVersionJob(worker, j); err != nil {
return err
}
r := s.db.WithContext(worker).Model(&KnowledgeVersionJob{}).Where("id=? AND lease_token=? AND status='running'", j.ID, j.LeaseToken).Updates(map[string]any{"processed": n, "total": total})
if r.Error != nil {
return r.Error
}
if r.RowsAffected != 1 {
return fmt.Errorf("copy lease lost")
}
return nil
})
}
if err == nil {
_, hash, err = s.resourceSnapshot(s.db.WithContext(worker), worker, "knowledge", j.SourceID)
if err == nil && hash != j.ExpectedHash {
err = fmt.Errorf("复制期间原资料发生变化,请重新提交版本")
}
}
if err == nil {
err = verifyKnowledgeVersionCopy(s.db.WithContext(worker), j)
}
if err == nil {
_, err = s.authorizeKnowledgeVersionJob(worker, j)
}
finish, cancelFinish := context.WithTimeout(context.WithoutCancel(ctx), 30*time.Second)
defer cancelFinish()
return s.finishKnowledgeVersionJob(finish, j, err)
}
func verifyKnowledgeVersionCopy(tx *gorm.DB, j KnowledgeVersionJob) error {
var source, target []types.Knowledge
if err := tx.Where("tenant_id=? AND knowledge_base_id=?", j.TenantID, j.SourceID).Find(&source).Error; err != nil {
return err
}
if err := tx.Where("tenant_id=? AND knowledge_base_id=?", j.TenantID, j.TargetID).Find(&target).Error; err != nil {
return err
}
if len(source) != len(target) {
return fmt.Errorf("复制后的知识数量不一致")
}
paths := map[string]bool{}
for _, d := range source {
if d.FilePath != "" {
paths[d.FilePath] = true
}
}
for _, d := range target {
if d.ParseStatus != types.ParseStatusCompleted || paths[d.FilePath] {
return fmt.Errorf("复制文件尚未完成或仍引用原文件")
}
}
var a, b int64
if err := tx.Model(&types.Chunk{}).Where("tenant_id=? AND knowledge_base_id=?", j.TenantID, j.SourceID).Count(&a).Error; err != nil {
return err
}
if err := tx.Model(&types.Chunk{}).Where("tenant_id=? AND knowledge_base_id=?", j.TenantID, j.TargetID).Count(&b).Error; err != nil {
return err
}
if a != b {
return fmt.Errorf("复制后的片段数量不一致")
}
return nil
}
func (s *Service) finishKnowledgeVersionJob(ctx context.Context, j KnowledgeVersionJob, copyErr error) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var current KnowledgeVersionJob
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=? AND lease_token=? AND status='running'", j.ID, j.LeaseToken).First(&current).Error; err != nil {
return err
}
current.LeaseUntil = nil
current.LeaseToken = ""
if copyErr != nil {
return s.failKnowledgeVersionJob(tx, &current, copyErr)
} else {
current.Status = "completed"
current.Processed = current.Total
state := "snapshot"
temporary := true
if j.Purpose == "fork" {
state = ""
temporary = false
if err := tx.Model(&ResourceFork{}).Where("id=?", j.ForkID).Update("status", "ready").Error; err != nil {
return err
}
} else {
if err := tx.Model(&ResourceSubmission{}).Where("id=? AND status='snapshotting'", j.SubmissionID).Update("status", "pending").Error; err != nil {
return err
}
}
if err := tx.Model(&types.KnowledgeBase{}).Where("id=? AND tenant_id=?", j.TargetID, j.TenantID).Updates(map[string]any{"version_state": state, "is_temporary": temporary}).Error; err != nil {
return err
}
}
return tx.Save(&current).Error
})
}
func (s *Service) failKnowledgeVersionJob(tx *gorm.DB, job *KnowledgeVersionJob, cause error) error {
job.Status, job.LeaseToken, job.LeaseUntil = "failed", "", nil
job.Error = "知识库版本复制失败,请重试;若原资料已修改或删除,请重新提交。"
if job.Purpose == "fork" {
if err := tx.Model(&ResourceFork{}).Where("tenant_id=? AND id=?", job.TenantID, job.ForkID).Update("status", "failed").Error; err != nil {
return err
}
} else if err := tx.Model(&ResourceSubmission{}).Where("tenant_id=? AND id=?", job.TenantID, job.SubmissionID).Update("status", "snapshot_failed").Error; err != nil {
return err
}
// Keep storage/provider diagnostics in the protected audit ledger.
details, err := json.Marshal(map[string]any{"error": cause.Error()})
if err != nil {
return err
}
if err := tx.Create(&types.AuditLog{TenantID: job.TenantID, ActorUserID: job.OwnerID, ActorRole: "system", Action: "resource.knowledge_copy_failed", TargetType: "knowledge_version_job", TargetID: job.ID, Outcome: types.AuditOutcomeFailed, Details: types.JSON(details), CreatedAt: time.Now()}).Error; err != nil {
return err
}
return tx.Save(job).Error
}
@@ -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")
}
}
@@ -0,0 +1,153 @@
package accesscontrol
import (
"context"
"errors"
"fmt"
"time"
"github.com/Tencent/WeKnora/internal/knowledgeversion"
"github.com/Tencent/WeKnora/internal/types"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// A copy can run for two hours. Wait longer before deleting abandoned targets,
// including targets left by a process whose heartbeat stopped before its work.
const knowledgeVersionCleanupDelay = 3 * time.Hour
func (s *Service) CleanupAbandonedKnowledgeVersions(ctx context.Context) error {
cleaner, ok := s.versionKnowledge.(interface {
CleanupKnowledgeVersion(context.Context, string) error
})
if !ok {
return nil
}
now := time.Now()
var job KnowledgeVersionJob
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Clauses(clause.Locking{Strength: "UPDATE", Options: "SKIP LOCKED"}).
Where("status IN ? AND updated_at < ?", []string{"completed", "failed"}, now.Add(-knowledgeVersionCleanupDelay)).
Where(`(abandoned_ids IS NOT NULL AND CAST(abandoned_ids AS TEXT) <> '[]' AND CAST(abandoned_ids AS TEXT) <> 'null') OR (status='failed' AND EXISTS (SELECT 1 FROM knowledge_bases kb WHERE kb.id=workbench_knowledge_version_jobs.target_id AND kb.tenant_id=workbench_knowledge_version_jobs.tenant_id AND kb.is_temporary=true AND kb.version_state='copying' AND kb.deleted_at IS NULL))`).
Order("updated_at").Limit(1).Find(&job)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
return gorm.ErrRecordNotFound
}
return tx.Model(&job).UpdateColumn("updated_at", now).Error
})
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
if err != nil {
return err
}
worker, cancel := context.WithTimeout(knowledgeversion.WithWorker(ctx), 5*time.Minute)
defer cancel()
worker = context.WithValue(worker, types.TenantIDContextKey, job.TenantID)
worker = context.WithValue(worker, types.UserIDContextKey, job.OwnerID)
worker = context.WithValue(worker, types.TenantRoleContextKey, types.TenantRoleAdmin)
worker = context.WithValue(worker, types.SystemAdminContextKey, false)
targets := append([]string{}, job.AbandonedIDs...)
if job.Status == "failed" {
targets = append(targets, job.TargetID)
}
seen := map[string]bool{}
for _, target := range targets {
if target == "" || seen[target] {
continue
}
seen[target] = true
var kb types.KnowledgeBase
result := s.db.WithContext(worker).Where("tenant_id=? AND id=?", job.TenantID, target).Limit(1).Find(&kb)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
if err := s.finishKnowledgeVersionCleanup(worker, job.ID, job.TenantID, target); err != nil {
return err
}
continue
}
if !kb.IsTemporary || kb.VersionState != "copying" {
return fmt.Errorf("refusing to clean non-staging knowledge version %s", target)
}
if err := cleaner.CleanupKnowledgeVersion(worker, target); err != nil {
return err
}
if err := s.finishKnowledgeVersionCleanup(worker, job.ID, job.TenantID, target); err != nil {
return err
}
}
return nil
}
func (s *Service) finishKnowledgeVersionCleanup(ctx context.Context, jobID string, tenant uint64, target string) error {
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var job KnowledgeVersionJob
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id=? AND tenant_id=?", jobID, tenant).First(&job).Error; err != nil {
return err
}
allowed := job.Status == "failed" && job.TargetID == target
remaining := make(types.StringArray, 0, len(job.AbandonedIDs))
for _, id := range job.AbandonedIDs {
if id == target {
allowed = true
} else {
remaining = append(remaining, id)
}
}
if !allowed || job.TargetID == target && job.Status != "failed" {
return fmt.Errorf("staging cleanup ownership changed")
}
var kb types.KnowledgeBase
result := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND id=?", tenant, target).Limit(1).Find(&kb)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 1 {
if !kb.IsTemporary || kb.VersionState != "copying" {
return fmt.Errorf("staging cleanup target became published")
}
var docs []types.Knowledge
if err := tx.Unscoped().Where("tenant_id=? AND knowledge_base_id=?", tenant, target).Find(&docs).Error; err != nil {
return err
}
var size int64
for _, doc := range docs {
size += doc.StorageSize
}
var chunks int64
if err := tx.Model(&types.Chunk{}).Where("tenant_id=? AND knowledge_base_id=?", tenant, target).Count(&chunks).Error; err != nil {
return err
}
if chunks != 0 {
return fmt.Errorf("staging external cleanup is incomplete")
}
if size > 0 {
r := tx.Model(&types.Tenant{}).Where("id=? AND storage_used>=?", tenant, size).UpdateColumn("storage_used", gorm.Expr("storage_used - ?", size))
if r.Error != nil {
return r.Error
}
if r.RowsAffected != 1 {
return fmt.Errorf("staging storage accounting mismatch")
}
}
if err := knowledgeversion.DeleteGraph(tx, tenant, target); err != nil {
return err
}
if err := tx.Unscoped().Where("tenant_id=? AND knowledge_base_id=?", tenant, target).Delete(&types.Chunk{}).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("tenant_id=? AND knowledge_base_id=?", tenant, target).Delete(&types.Knowledge{}).Error; err != nil {
return err
}
if err := tx.Unscoped().Where("tenant_id=? AND id=?", tenant, target).Delete(&types.KnowledgeBase{}).Error; err != nil {
return err
}
}
return tx.Model(&job).Updates(map[string]any{"abandoned_ids": remaining}).Error
})
}
@@ -0,0 +1,152 @@
package accesscontrol
import (
"context"
"errors"
"fmt"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/knowledgeversion"
"github.com/Tencent/WeKnora/internal/types"
"github.com/google/uuid"
"github.com/stretchr/testify/require"
"gorm.io/gorm"
)
type versionCleanupFixture struct {
*versionCopyFixture
targets []string
fail bool
after func(string)
}
func (f *versionCleanupFixture) CleanupKnowledgeVersion(ctx context.Context, target string) error {
if !knowledgeversion.IsWorker(ctx) || types.MustTenantIDFromContext(ctx) != 1 {
return fmt.Errorf("wrong cleanup workspace")
}
f.targets = append(f.targets, target)
if f.fail {
return fmt.Errorf("object store unavailable")
}
if f.after != nil {
f.after(target)
}
return nil
}
func newVersionCleanupFixture(t *testing.T) (*Service, *versionCleanupFixture) {
t.Helper()
s, f, _ := knowledgeVersionFixture(t)
require.NoError(t, s.db.AutoMigrate(&types.Tenant{}))
require.NoError(t, s.db.Create(&types.Tenant{ID: 1, Name: "cleanup", StorageUsed: 100}).Error)
c := &versionCleanupFixture{versionCopyFixture: f}
s.SetKnowledgeVersionAdapter(c)
return s, c
}
func addCleanupStage(t *testing.T, s *Service, status string, age time.Duration, temporary bool, state string) KnowledgeVersionJob {
t.Helper()
id := uuid.NewString()
require.NoError(t, s.db.Create(&types.KnowledgeBase{ID: id, TenantID: 1, CreatorID: "alice", Type: "document", IsTemporary: temporary, VersionState: state}).Error)
require.NoError(t, s.db.Create(&types.Knowledge{ID: uuid.NewString(), TenantID: 1, KnowledgeBaseID: id, StorageSize: 25}).Error)
job := KnowledgeVersionJob{ID: uuid.NewString(), TenantID: 1, OwnerID: "alice", TargetID: id, Status: status, Attempts: 1, AbandonedIDs: types.StringArray{}, UpdatedAt: time.Now().Add(-age)}
require.NoError(t, s.db.Create(&job).Error)
return job
}
func TestKnowledgeVersionCleanupDelayAndAccounting(t *testing.T) {
s, f := newVersionCleanupFixture(t)
recent := addCleanupStage(t, s, "failed", time.Hour, true, "copying")
running := addCleanupStage(t, s, "running", 4*time.Hour, true, "copying")
snapshot := addCleanupStage(t, s, "failed", 4*time.Hour, true, "snapshot")
published := addCleanupStage(t, s, "failed", 4*time.Hour, false, "")
old := addCleanupStage(t, s, "failed", 4*time.Hour, true, "copying")
require.NoError(t, s.CleanupAbandonedKnowledgeVersions(context.Background()))
require.Equal(t, []string{old.TargetID}, f.targets)
var count int64
require.NoError(t, s.db.Unscoped().Model(&types.Knowledge{}).Where("knowledge_base_id=?", old.TargetID).Count(&count).Error)
require.Zero(t, count)
var tenant types.Tenant
require.NoError(t, s.db.First(&tenant, 1).Error)
require.EqualValues(t, 75, tenant.StorageUsed)
require.NoError(t, s.CleanupAbandonedKnowledgeVersions(context.Background()))
require.NoError(t, s.db.First(&tenant, 1).Error)
require.EqualValues(t, 75, tenant.StorageUsed)
for _, job := range []KnowledgeVersionJob{recent, running, snapshot, published} {
require.NoError(t, s.db.Model(&types.KnowledgeBase{}).Where("id=?", job.TargetID).Count(&count).Error)
require.EqualValues(t, 1, count)
}
}
func TestKnowledgeVersionCleanupRetriesAndRetainsAccountingUntilSuccess(t *testing.T) {
s, f := newVersionCleanupFixture(t)
job := addCleanupStage(t, s, "failed", 4*time.Hour, true, "copying")
f.fail = true
require.ErrorContains(t, s.CleanupAbandonedKnowledgeVersions(context.Background()), "object store unavailable")
var tenant types.Tenant
require.NoError(t, s.db.First(&tenant, 1).Error)
require.EqualValues(t, 100, tenant.StorageUsed)
f.fail = false
require.NoError(t, s.db.Model(&job).UpdateColumn("updated_at", time.Now().Add(-4*time.Hour)).Error)
require.NoError(t, s.CleanupAbandonedKnowledgeVersions(context.Background()))
require.NoError(t, s.db.First(&tenant, 1).Error)
require.EqualValues(t, 75, tenant.StorageUsed)
}
func TestKnowledgeVersionCleanupPreservesRetryTarget(t *testing.T) {
s, f := newVersionCleanupFixture(t)
job := addCleanupStage(t, s, "failed", 4*time.Hour, true, "copying")
newTarget := uuid.NewString()
f.after = func(target string) {
require.NoError(t, s.db.Create(&types.KnowledgeBase{ID: newTarget, TenantID: 1, IsTemporary: true, VersionState: "copying"}).Error)
require.NoError(t, s.db.Model(&job).Updates(map[string]any{"status": "running", "target_id": newTarget, "abandoned_ids": types.StringArray{target}}).Error)
}
require.NoError(t, s.CleanupAbandonedKnowledgeVersions(context.Background()))
require.NoError(t, s.db.First(&job, "id=?", job.ID).Error)
require.Equal(t, newTarget, job.TargetID)
require.Empty(t, job.AbandonedIDs)
var count int64
require.NoError(t, s.db.Model(&types.KnowledgeBase{}).Where("id=?", newTarget).Count(&count).Error)
require.EqualValues(t, 1, count)
}
func TestKnowledgeVersionCleanupRejectsStorageMismatchAtomically(t *testing.T) {
s, _ := newVersionCleanupFixture(t)
job := addCleanupStage(t, s, "failed", 4*time.Hour, true, "copying")
require.NoError(t, s.db.Model(&types.Tenant{}).Where("id=1").UpdateColumn("storage_used", 0).Error)
require.ErrorContains(t, s.CleanupAbandonedKnowledgeVersions(context.Background()), "accounting mismatch")
var count int64
require.NoError(t, s.db.Model(&types.Knowledge{}).Where("knowledge_base_id=?", job.TargetID).Count(&count).Error)
require.EqualValues(t, 1, count)
}
func TestKnowledgeVersionIdleQueueIsExpected(t *testing.T) {
s, _, _ := knowledgeVersionFixture(t)
require.True(t, errors.Is(s.RunKnowledgeVersionJob(context.Background()), gorm.ErrRecordNotFound))
}
func TestKnowledgeVersionGraphFingerprintAndScopedCleanup(t *testing.T) {
s, _ := newVersionCleanupFixture(t)
require.NoError(t, s.db.AutoMigrate(&knowledgeversion.GraphRecord{}, &knowledgeversion.GraphSource{}))
job := addCleanupStage(t, s, "failed", 4*time.Hour, true, "copying")
for _, kb := range []string{job.TargetID, "published-keep"} {
require.NoError(t, s.db.Create(&knowledgeversion.GraphRecord{TenantID: 1, KnowledgeBaseID: kb, SourceTable: "knowledgeentity", LegacyID: 7, Payload: types.JSON(`{"id":7}`)}).Error)
require.NoError(t, s.db.Create(&knowledgeversion.GraphSource{TenantID: 1, KnowledgeBaseID: kb, SourceChunkID: "old", Excerpt: "preserve", MappingState: "unmapped"}).Error)
}
before, err := knowledgeversion.GraphFingerprint(s.db, 1, "published-keep")
require.NoError(t, err)
require.NotEmpty(t, before)
require.NoError(t, s.db.Model(&knowledgeversion.GraphSource{}).Where("knowledge_base_id=?", "published-keep").Update("excerpt", "changed provenance").Error)
after, err := knowledgeversion.GraphFingerprint(s.db, 1, "published-keep")
require.NoError(t, err)
require.NotEqual(t, before, after)
require.NoError(t, s.CleanupAbandonedKnowledgeVersions(context.Background()))
for _, model := range []any{&knowledgeversion.GraphRecord{}, &knowledgeversion.GraphSource{}} {
var count int64
require.NoError(t, s.db.Model(model).Where("knowledge_base_id=?", job.TargetID).Count(&count).Error)
require.Zero(t, count)
require.NoError(t, s.db.Model(model).Where("knowledge_base_id=?", "published-keep").Count(&count).Error)
require.EqualValues(t, 1, count)
}
}
@@ -0,0 +1,91 @@
package accesscontrol
import (
"context"
"fmt"
"strconv"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type knowledgeReviewDocument struct {
ID string `json:"id"`
Title string `json:"title"`
Type string `json:"type"`
}
type knowledgeReviewChunk struct {
ID string `json:"id"`
Content string `json:"content"`
ChunkType string `json:"chunk_type"`
ChunkIndex int `json:"chunk_index"`
IsEnabled bool `json:"is_enabled"`
}
type knowledgeReviewPage struct {
Documents []knowledgeReviewDocument `json:"documents,omitempty"`
Document *knowledgeReviewDocument `json:"document,omitempty"`
Chunks []knowledgeReviewChunk `json:"chunks,omitempty"`
Page int `json:"page"`
PageSize int `json:"page_size"`
Total int64 `json:"total"`
}
func (s *Service) GetKnowledgeVersionReview(c *gin.Context) {
page, err := strconv.Atoi(c.DefaultQuery("page", "1"))
if err != nil || page < 1 || page > 100000 {
versionResult(c, errInvalid, nil)
return
}
result, err := s.knowledgeVersionReview(c.Request.Context(), c.Param("id"), c.Query("document_id"), page)
versionResult(c, err, result)
}
// Review reads only an immutable stage, without granting ordinary KB access or
// exposing file handles, provider configuration, or download URLs.
func (s *Service) knowledgeVersionReview(ctx context.Context, id, document string, page int) (*knowledgeReviewPage, error) {
if err := versionPermission(ctx, "knowledge", false); err != nil {
return nil, err
}
tenant, user := identity(ctx)
result := &knowledgeReviewPage{Page: page, PageSize: 20}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var submission ResourceSubmission
q := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("tenant_id=? AND id=? AND kind='knowledge'", tenant, id)
if !administrator(ctx) {
q = q.Where("owner_id=?", user)
}
if err := q.First(&submission).Error; err != nil {
return err
}
if submission.SnapshotID == "" || (submission.Status != "pending" && submission.Status != "rejected" && submission.Status != "withdrawn") {
return fmt.Errorf("%w: 当前记录没有可查看的只读知识快照;已批准记录请查看已发布知识库", errConflict)
}
var kb types.KnowledgeBase
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("tenant_id=? AND id=? AND is_temporary=true AND version_state='snapshot'", tenant, submission.SnapshotID).First(&kb).Error; err != nil {
return err
}
docs := tx.Model(&types.Knowledge{}).Where("tenant_id=? AND knowledge_base_id=?", tenant, kb.ID)
if document == "" {
if err := docs.Count(&result.Total).Error; err != nil {
return err
}
result.Documents = []knowledgeReviewDocument{}
return docs.Select("id,title,type").Order("title,id").Offset((page - 1) * result.PageSize).Limit(result.PageSize).Find(&result.Documents).Error
}
result.Document = &knowledgeReviewDocument{}
if err := docs.Select("id,title,type").Where("id=?", document).Take(result.Document).Error; err != nil {
return err
}
chunks := tx.Model(&types.Chunk{}).Where("tenant_id=? AND knowledge_base_id=? AND knowledge_id=?", tenant, kb.ID, document)
if err := chunks.Count(&result.Total).Error; err != nil {
return err
}
result.Chunks = []knowledgeReviewChunk{}
return chunks.Select("id,content,chunk_type,chunk_index,is_enabled").Order("chunk_index,id").Offset((page - 1) * result.PageSize).Limit(result.PageSize).Find(&result.Chunks).Error
})
return result, err
}
@@ -0,0 +1,63 @@
package accesscontrol
import (
"errors"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"gorm.io/gorm"
)
func TestKnowledgeReviewIsScopedImmutableAndComplete(t *testing.T) {
s, _, source := knowledgeVersionFixture(t)
alice := versionContext(1, "alice", false)
f, err := s.forkResource(alice, "knowledge", source)
if err != nil {
t.Fatal(err)
}
runVersionJob(t, s)
r, err := s.submitResourceVersion(alice, f.ID, "")
if err != nil {
t.Fatal(err)
}
if _, err := s.knowledgeVersionReview(alice, r.ID, "", 1); !errors.Is(err, errConflict) {
t.Fatal("unfinished snapshot readable", err)
}
runVersionJob(t, s)
page, err := s.knowledgeVersionReview(alice, r.ID, "", 1)
if err != nil || page.Total != 1 || len(page.Documents) != 1 {
t.Fatal("missing snapshot documents", page, err)
}
doc := page.Documents[0].ID
text, err := s.knowledgeVersionReview(versionContext(1, "owner", true), r.ID, doc, 1)
if err != nil || text.Total != 1 || text.Chunks[0].Content != "原片段" {
t.Fatal("missing full text", text, err)
}
if err := s.db.Model(&types.Chunk{}).Where("knowledge_base_id=?", f.PersonalID).Update("content", "later personal edit").Error; err != nil {
t.Fatal(err)
}
text, err = s.knowledgeVersionReview(alice, r.ID, doc, 1)
if err != nil || text.Chunks[0].Content != "原片段" {
t.Fatal("review followed live personal content", err)
}
for _, ctx := range []struct {
tenant uint64
user string
admin bool
}{{1, "bob", false}, {2, "alice", true}} {
if _, err := s.knowledgeVersionReview(versionContext(ctx.tenant, ctx.user, ctx.admin), r.ID, "", 1); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatal("snapshot authorization bypass", ctx, err)
}
}
var sourceDoc types.Knowledge
s.db.Where("knowledge_base_id=?", source).First(&sourceDoc)
if _, err := s.knowledgeVersionReview(alice, r.ID, sourceDoc.ID, 1); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatal("foreign document readable", err)
}
if err := s.reviewResourceVersion(versionContext(1, "owner", true), r.ID, true, ""); err != nil {
t.Fatal(err)
}
if _, err := s.knowledgeVersionReview(alice, r.ID, doc, 1); !errors.Is(err, errConflict) {
t.Fatal("mutable publication presented as immutable snapshot", err)
}
}
@@ -0,0 +1,468 @@
package accesscontrol
import (
"context"
"errors"
"fmt"
"net/http/httptest"
"os"
"path/filepath"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/accesspolicy"
"github.com/Tencent/WeKnora/internal/knowledgeversion"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
)
type versionCopyFixture struct {
db *gorm.DB
dir string
fail bool
afterDocument func(int)
afterCopy func()
}
func (f *versionCopyFixture) CloneKnowledgeVersion(ctx context.Context, source, target string, progress func(int, int) error) error {
if !knowledgeversion.IsWorker(ctx) {
return fmt.Errorf("missing worker context")
}
var docs []types.Knowledge
if err := f.db.Where("knowledge_base_id=?", source).Find(&docs).Error; err != nil {
return err
}
if err := progress(0, len(docs)); err != nil {
return err
}
for i, src := range docs {
dst := src
dst.ID = uuid.NewString()
dst.KnowledgeBaseID = target
if src.FilePath != "" {
b, err := os.ReadFile(src.FilePath)
if err != nil {
return err
}
dst.FilePath = filepath.Join(f.dir, dst.ID)
if err = os.WriteFile(dst.FilePath, b, 0600); err != nil {
return err
}
}
if err := f.db.Create(&dst).Error; err != nil {
return err
}
if f.fail {
return fmt.Errorf("injected storage outage")
}
var chunks []types.Chunk
if err := f.db.Where("knowledge_id=?", src.ID).Find(&chunks).Error; err != nil {
return err
}
for _, c := range chunks {
c.ID = uuid.NewString()
c.SeqID = 0
c.KnowledgeID = dst.ID
c.KnowledgeBaseID = target
if err := f.db.Create(&c).Error; err != nil {
return err
}
}
if f.afterDocument != nil {
f.afterDocument(i + 1)
}
if err := progress(i+1, len(docs)); err != nil {
return err
}
}
if f.afterCopy != nil {
f.afterCopy()
}
return nil
}
func knowledgeVersionFixture(t *testing.T) (*Service, *versionCopyFixture, string) {
t.Helper()
s := versionDB(t)
if err := s.db.AutoMigrate(&types.TenantMember{}, &types.Chunk{}, &types.KnowledgeTag{}, &types.KnowledgeTagRelation{}, &KnowledgeVersionJob{}, &KnowledgeVersionAlias{}); err != nil {
t.Fatal(err)
}
for _, statement := range []string{
"ALTER TABLE users ADD COLUMN tenant_id INTEGER DEFAULT 1",
"ALTER TABLE users ADD COLUMN is_active BOOLEAN DEFAULT TRUE",
"ALTER TABLE users ADD COLUMN is_system_admin BOOLEAN DEFAULT FALSE",
"ALTER TABLE users ADD COLUMN can_access_all_tenants BOOLEAN DEFAULT FALSE",
} {
if err := s.db.Exec(statement).Error; err != nil {
t.Fatal(err)
}
}
for _, id := range []string{"owner", "alice", "bob"} {
if err := s.db.Table("users").Create(map[string]any{"id": id, "username": id, "is_active": true}).Error; err != nil {
t.Fatal(err)
}
if err := s.db.Create(&types.TenantMember{TenantID: 1, UserID: id, Role: types.TenantRoleContributor, Status: types.TenantMemberStatusActive}).Error; err != nil {
t.Fatal(err)
}
}
fake := &versionCopyFixture{db: s.db, dir: t.TempDir()}
s.SetKnowledgeVersionAdapter(fake)
kb := types.KnowledgeBase{ID: uuid.NewString(), TenantID: 1, CreatorID: "owner", Name: "知识原件", Type: "document"}
if err := s.db.Create(&kb).Error; err != nil {
t.Fatal(err)
}
if err := s.db.Create(&Publication{TenantID: 1, Kind: "knowledge", ResourceID: kb.ID, Global: true}).Error; err != nil {
t.Fatal(err)
}
path := filepath.Join(fake.dir, "source.txt")
if err := os.WriteFile(path, []byte("independent content"), 0600); err != nil {
t.Fatal(err)
}
doc := types.Knowledge{ID: uuid.NewString(), TenantID: 1, KnowledgeBaseID: kb.ID, Title: "原文", Type: "file", FilePath: path, ParseStatus: types.ParseStatusCompleted, CustomMetadata: types.JSON(`{}`)}
if err := s.db.Create(&doc).Error; err != nil {
t.Fatal(err)
}
if err := s.db.Create(&types.Chunk{ID: uuid.NewString(), TenantID: 1, KnowledgeBaseID: kb.ID, KnowledgeID: doc.ID, Content: "原片段", SourceContent: "原片段", ChunkType: types.ChunkTypeText, IndexStatus: "ready"}).Error; err != nil {
t.Fatal(err)
}
return s, fake, kb.ID
}
func refreshFork(t *testing.T, s *Service, f *ResourceFork) {
t.Helper()
if err := s.db.First(f, "id=?", f.ID).Error; err != nil {
t.Fatal(err)
}
}
func runVersionJob(t *testing.T, s *Service) {
t.Helper()
if err := s.RunKnowledgeVersionJob(context.Background()); err != nil {
t.Fatal(err)
}
}
func forkVersionJob(t *testing.T, s *Service, f *ResourceFork) KnowledgeVersionJob {
t.Helper()
var j KnowledgeVersionJob
if err := s.db.Where("fork_id=? AND purpose='fork'", f.ID).First(&j).Error; err != nil {
t.Fatal(err)
}
return j
}
func TestKnowledgeVersionImmutableApprovalAndAlias(t *testing.T) {
s, _, id := knowledgeVersionFixture(t)
ctx := versionContext(1, "alice", false)
admin := versionContext(1, "owner", true)
f, err := s.forkResource(ctx, "knowledge", id)
if err != nil {
t.Fatal(err)
}
if f.Status != "copying" {
t.Fatal("copy was exposed synchronously")
}
if _, err = s.submitResourceVersion(ctx, f.ID, ""); !errors.Is(err, errConflict) {
t.Fatal("unfinished fork submitted", err)
}
runVersionJob(t, s)
refreshFork(t, s, f)
if f.Status != "ready" {
t.Fatal("fork not completed", forkVersionJob(t, s, f))
}
s.db.Model(&types.Chunk{}).Where("knowledge_base_id=?", f.PersonalID).Update("content", "submitted edit")
r, err := s.submitResourceVersion(ctx, f.ID, "review")
if err != nil {
t.Fatal(err)
}
if r.Status != "snapshotting" {
t.Fatal("submission exposed before file copy")
}
if err = s.reviewResourceVersion(admin, r.ID, true, ""); !errors.Is(err, errConflict) {
t.Fatal("unfinished snapshot approved", err)
}
runVersionJob(t, s)
s.db.First(r, "id=?", r.ID)
if r.Status != "pending" {
t.Fatal("snapshot not pending", r.Status)
}
if _, err := knowledgeVersionSnapshot(ctx, s.db, r.SnapshotID); !errors.Is(err, errConflict) {
t.Fatal("snapshot leaked to normal context", err)
}
s.db.Model(&types.Chunk{}).Where("knowledge_base_id=?", f.PersonalID).Update("content", "later personal edit")
if err = s.reviewResourceVersion(ctx, r.ID, true, ""); !errors.Is(err, errVersionDenied) {
t.Fatal("ordinary user approved", err)
}
if err = s.reviewResourceVersion(admin, r.ID, true, "通过"); err != nil {
t.Fatal(err)
}
s.db.First(r, "id=?", r.ID)
if r.PublishedID == "" || r.PublishedID == id {
t.Fatal("missing distinct published revision")
}
var original, published types.KnowledgeBase
s.db.First(&original, "id=?", id)
s.db.First(&published, "id=?", r.PublishedID)
if !original.IsTemporary || published.IsTemporary || published.CreatorID != "owner" {
t.Fatal("catalog promotion incorrect")
}
var chunk types.Chunk
s.db.Where("knowledge_base_id=?", r.PublishedID).First(&chunk)
if chunk.Content != "submitted edit" {
t.Fatal("approval used mutable personal version", chunk.Content)
}
var oldDoc, newDoc types.Knowledge
s.db.Where("knowledge_base_id=?", id).First(&oldDoc)
s.db.Where("knowledge_base_id=?", r.PublishedID).First(&newDoc)
if oldDoc.FilePath == newDoc.FilePath {
t.Fatal("shared raw file")
}
if err = os.Remove(oldDoc.FilePath); err != nil {
t.Fatal(err)
}
if b, err := os.ReadFile(newDoc.FilePath); err != nil || string(b) != "independent content" {
t.Fatal("source deletion damaged published file", err)
}
latest, err := resolveKnowledgeVersion(s.db, 1, id)
if err != nil || latest != r.PublishedID {
t.Fatal("alias not advanced", latest, err)
}
again, err := s.forkResource(ctx, "knowledge", id)
if err != nil || again.ID != f.ID {
t.Fatal("old catalog link created duplicate fork", err)
}
if _, err = sourceView(s.db, 1, "knowledge", id); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatal("old revision remained in new selections", err)
}
}
func TestKnowledgeVersionDurableRetryAndIsolation(t *testing.T) {
s, fake, id := knowledgeVersionFixture(t)
ctx := versionContext(1, "alice", false)
f, err := s.forkResource(ctx, "knowledge", id)
if err != nil {
t.Fatal(err)
}
oldTarget := f.PersonalID
fake.fail = true
runVersionJob(t, s)
refreshFork(t, s, f)
j := forkVersionJob(t, s, f)
if j.Status != "failed" || f.Status != "failed" {
t.Fatal("failure was not persisted")
}
if err = s.retryKnowledgeVersionJob(versionContext(2, "alice", true), j.ID); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatal("cross tenant retry", err)
}
if err = s.retryKnowledgeVersionJob(versionContext(1, "bob", false), j.ID); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatal("cross user retry", err)
}
denied := accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{Restricted: true, Permissions: map[string]bool{"knowledge.read": true}})
if err = s.retryKnowledgeVersionJob(denied, j.ID); !errors.Is(err, errVersionDenied) {
t.Fatal("module denial bypass", err)
}
s.db.Model(&types.Chunk{}).Where("knowledge_base_id=?", id).Update("content", "source changed before retry")
if err = s.retryKnowledgeVersionJob(ctx, j.ID); err != nil {
t.Fatal(err)
}
fake.fail = false
// A fresh service instance simulates process restart; no in-memory queue is needed.
restarted := NewService(s.db)
restarted.SetKnowledgeVersionAdapter(fake)
runVersionJob(t, restarted)
refreshFork(t, s, f)
j = forkVersionJob(t, s, f)
if f.Status != "ready" || f.PersonalID == oldTarget || j.Attempts != 2 || len(j.AbandonedIDs) != 1 {
t.Fatal("retry reused partial target", j)
}
var old types.KnowledgeBase
s.db.First(&old, "id=?", oldTarget)
if old.VersionState != "copying" || !old.IsTemporary {
t.Fatal("partial version became visible")
}
}
func TestKnowledgeVersionDetectsChangesDuringSnapshot(t *testing.T) {
s, fake, id := knowledgeVersionFixture(t)
ctx := versionContext(1, "alice", false)
f, err := s.forkResource(ctx, "knowledge", id)
if err != nil {
t.Fatal(err)
}
runVersionJob(t, s)
refreshFork(t, s, f)
r, err := s.submitResourceVersion(ctx, f.ID, "")
if err != nil {
t.Fatal(err)
}
fake.afterCopy = func() {
s.db.Model(&types.Chunk{}).Where("knowledge_base_id=?", f.PersonalID).Update("content", "edited during snapshot")
}
runVersionJob(t, s)
s.db.First(r, "id=?", r.ID)
if r.Status != "snapshot_failed" {
t.Fatal("torn snapshot exposed", r.Status)
}
var j KnowledgeVersionJob
s.db.Where("submission_id=?", r.ID).First(&j)
if err = s.retryKnowledgeVersionJob(ctx, j.ID); !errors.Is(err, errConflict) {
t.Fatal("changed immutable proposal retried", err)
}
if _, err = s.submitResourceVersion(ctx, f.ID, "new submission"); err != nil {
t.Fatal("failed snapshot prevented new submission", err)
}
}
func TestKnowledgeVersionStaleLeaseCannotExposeTarget(t *testing.T) {
s, fake, id := knowledgeVersionFixture(t)
ctx := versionContext(1, "alice", false)
f, err := s.forkResource(ctx, "knowledge", id)
if err != nil {
t.Fatal(err)
}
fake.afterCopy = func() {
s.db.Model(&KnowledgeVersionJob{}).Where("fork_id=?", f.ID).Updates(map[string]any{"lease_token": "replacement", "lease_until": time.Now().Add(-time.Minute)})
}
if err = s.RunKnowledgeVersionJob(context.Background()); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatal("stale worker finalized", err)
}
refreshFork(t, s, f)
if f.Status != "copying" {
t.Fatal("stale copy exposed")
}
oldTarget := f.PersonalID
fake.afterCopy = nil
runVersionJob(t, s)
refreshFork(t, s, f)
if f.Status != "ready" || f.PersonalID == oldTarget {
t.Fatal("expired lease did not allocate independent target")
}
}
func TestKnowledgeVersionFingerprintCoversCategoriesAndSourceText(t *testing.T) {
s, _, id := knowledgeVersionFixture(t)
ctx := versionContext(1, "alice", false)
_, a, err := s.resourceSnapshot(s.db, ctx, "knowledge", id)
if err != nil {
t.Fatal(err)
}
s.db.Model(&types.Chunk{}).Where("knowledge_base_id=?", id).Update("source_content", "changed raw parser output")
_, b, _ := s.resourceSnapshot(s.db, ctx, "knowledge", id)
if a == b {
t.Fatal("raw source change ignored")
}
if err = s.db.Create(&types.KnowledgeTag{ID: uuid.NewString(), TenantID: 1, KnowledgeBaseID: id, Name: "分类"}).Error; err != nil {
t.Fatal(err)
}
_, c, _ := s.resourceSnapshot(s.db, ctx, "knowledge", id)
if b == c {
t.Fatal("category change ignored")
}
}
func TestKnowledgeVersionMissingSourceDoesNotBlockQueue(t *testing.T) {
s, fake, id := knowledgeVersionFixture(t)
ctx := versionContext(1, "alice", false)
f, err := s.forkResource(ctx, "knowledge", id)
if err != nil {
t.Fatal(err)
}
fake.fail = true
runVersionJob(t, s)
j := forkVersionJob(t, s, f)
if err = s.retryKnowledgeVersionJob(ctx, j.ID); err != nil {
t.Fatal(err)
}
if err = s.db.Delete(&types.KnowledgeBase{}, "id=?", id).Error; err != nil {
t.Fatal(err)
}
runVersionJob(t, s)
j = forkVersionJob(t, s, f)
if j.Status != "failed" || j.LeaseToken != "" {
t.Fatal("missing source left a blocking queue entry", j)
}
if err = s.RunKnowledgeVersionJob(context.Background()); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatal("failed source blocked queue", err)
}
var log types.AuditLog
if err = s.db.Where("target_id=? AND outcome=?", j.ID, types.AuditOutcomeFailed).First(&log).Error; err != nil {
t.Fatal("failure audit missing", err)
}
}
func TestKnowledgeVersionRejectsUnsupportedBeforeReservation(t *testing.T) {
s, _, id := knowledgeVersionFixture(t)
if err := s.db.Model(&types.Chunk{}).Where("knowledge_base_id=?", id).Update("chunk_type", types.ChunkTypeTableSummary).Error; err != nil {
t.Fatal(err)
}
if _, err := s.forkResource(versionContext(1, "alice", false), "knowledge", id); !errors.Is(err, errInvalid) {
t.Fatal("unsupported copy reserved", err)
}
var count int64
if err := s.db.Model(&KnowledgeVersionJob{}).Count(&count).Error; err != nil || count != 0 {
t.Fatal("unsupported request created a job", count, err)
}
}
func TestKnowledgeVersionApprovalConflictAndAliasChain(t *testing.T) {
s, _, id := knowledgeVersionFixture(t)
ctx, admin := versionContext(1, "alice", false), versionContext(1, "owner", true)
f, err := s.forkResource(ctx, "knowledge", id)
if err != nil {
t.Fatal(err)
}
runVersionJob(t, s)
refreshFork(t, s, f)
r, err := s.submitResourceVersion(ctx, f.ID, "")
if err != nil {
t.Fatal(err)
}
runVersionJob(t, s)
if err = s.db.Model(&types.Chunk{}).Where("knowledge_base_id=?", id).Update("content", "global edit").Error; err != nil {
t.Fatal(err)
}
if err = s.reviewResourceVersion(admin, r.ID, true, ""); !errors.Is(err, errConflict) {
t.Fatal("changed global source overwritten", err)
}
for _, action := range []struct {
id string
handler gin.HandlerFunc
}{{r.ID, s.WithdrawResourceVersion}, {f.ID, s.RebaseResourceVersion}} {
response := httptest.NewRecorder()
request, _ := gin.CreateTestContext(response)
request.Request = httptest.NewRequest("POST", "/", nil).WithContext(ctx)
request.Params = gin.Params{{Key: "id", Value: action.id}}
action.handler(request)
if response.Code != 200 {
t.Fatal("version action failed", response.Code, response.Body.String())
}
}
if err = s.db.Create(&ResourceHidden{TenantID: 1, OwnerID: "bob", Kind: "knowledge", ResourceID: id}).Error; err != nil {
t.Fatal(err)
}
previous := id
for i := 0; i < 2; i++ {
r, err = s.submitResourceVersion(ctx, f.ID, fmt.Sprintf("revision %d", i))
if err != nil {
t.Fatal(err)
}
runVersionJob(t, s)
if err = s.reviewResourceVersion(admin, r.ID, true, ""); err != nil {
t.Fatal(err)
}
if err = s.db.First(r, "id=?", r.ID).Error; err != nil {
t.Fatal(err)
}
for _, old := range []string{id, previous} {
latest, err := resolveKnowledgeVersion(s.db, 1, old)
if err != nil || latest != r.PublishedID {
t.Fatal("alias chain not advanced", old, latest, err)
}
}
var hidden ResourceHidden
if err = s.db.Where("tenant_id=1 AND owner_id='bob' AND resource_id=?", r.PublishedID).First(&hidden).Error; err != nil {
t.Fatal("personal hiding lost on approval", err)
}
previous = r.PublishedID
}
}
@@ -0,0 +1,461 @@
package accesscontrol
import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"unicode/utf8"
"github.com/Tencent/WeKnora/internal/accesspolicy"
"github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/workbench"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// KnowledgeVersionAdapter performs external work only in the durable worker.
// HTTP transactions reserve jobs; they never pretend file/index copies roll back.
type KnowledgeVersionAdapter interface {
CloneKnowledgeVersion(context.Context, string, string, func(int, int) error) error
}
func (s *Service) SetKnowledgeVersionAdapter(adapter KnowledgeVersionAdapter) {
s.versionKnowledge = adapter
}
type agentVersion struct {
Name string `json:"name"`
Description string `json:"description"`
Avatar string `json:"avatar"`
Config types.CustomAgentConfig `json:"config"`
UpdatedAt time.Time `json:"updated_at"`
}
type skillVersion struct {
Name string `json:"name"`
Description string `json:"description"`
Version string `json:"version"`
Instructions string `json:"instructions"`
UpdatedAt time.Time `json:"updated_at"`
}
type humanVersion struct {
Name string `json:"name"`
Description string `json:"description"`
Avatar string `json:"avatar"`
ModelID string `json:"model_id"`
Persona map[string]string `json:"persona"`
Revision int64 `json:"revision"`
}
type flowVersion struct {
Name string `json:"name"`
Data workbench.FlowData `json:"data"`
Version int `json:"version"`
}
func (s *Service) resourceSnapshot(tx *gorm.DB, ctx context.Context, kind, id string) (types.JSON, string, error) {
t, _ := identity(ctx)
switch kind {
case "agents":
var v types.CustomAgent
if err := tx.Where("tenant_id=? AND id=?", t, id).First(&v).Error; err != nil {
return nil, "", err
}
return fingerprint(agentVersion{v.Name, v.Description, v.Avatar, v.Config, v.UpdatedAt})
case "skills":
var v types.TenantSkillCatalogEntity
if err := tx.Where("tenant_id=? AND id=? AND instruction_only=true", t, id).First(&v).Error; err != nil {
return nil, "", err
}
return fingerprint(skillVersion{v.Name, v.Description, v.Version, v.Instructions, v.UpdatedAt})
case "digital-humans":
var v types.DigitalHuman
if err := tx.Where("tenant_id=? AND id=?", t, id).First(&v).Error; err != nil {
return nil, "", err
}
if v.ProcessingJobID != "" {
return nil, "", fmt.Errorf("%w: 数字人正在蒸馏", errConflict)
}
var version types.DigitalHumanVersion
if err := tx.Where("tenant_id=? AND digital_human_id=? AND number=?", t, id, v.LatestVersion).First(&version).Error; err != nil {
return nil, "", err
}
return fingerprint(humanVersion{v.Name, v.Description, v.Avatar, v.ModelID, version.Persona, v.Revision})
case "flows":
var v workbench.Resource
if err := tx.Where("tenant_id=? AND id=? AND kind='flows'", t, id).First(&v).Error; err != nil {
return nil, "", err
}
var d workbench.FlowData
b, err := json.Marshal(v.Data)
if err != nil {
return nil, "", err
}
if err = json.Unmarshal(b, &d); err != nil {
return nil, "", err
}
return fingerprint(flowVersion{v.Name, d, v.Version})
case "knowledge":
if s.versionKnowledge == nil {
return nil, "", fmt.Errorf("%w: 知识库个人版本复制服务尚未配置", errInvalid)
}
v, err := knowledgeVersionSnapshot(ctx, tx, id)
if err != nil {
return nil, "", err
}
return fingerprint(v)
}
return nil, "", errInvalid
}
func (s *Service) cloneVersionResource(tx *gorm.DB, ctx context.Context, kind, source, target string) error {
t, u := identity(ctx)
switch kind {
case "agents":
var v types.CustomAgent
if err := tx.Where("tenant_id=? AND id=?", t, source).First(&v).Error; err != nil {
return err
}
var bound int64
if err := tx.Model(&types.DigitalHumanBinding{}).Where("tenant_id=? AND agent_id=?", t, source).Count(&bound).Error; err != nil {
return err
}
if bound > 0 {
return fmt.Errorf("%w: 这个 Agent 已绑定数字人,请创建该数字人的个人版本", errInvalid)
}
v.ID = target
v.CreatedBy = u
v.Visibility = "private"
v.IsBuiltin = false
v.CreatedAt = time.Time{}
v.UpdatedAt = time.Time{}
v.DeletedAt = gorm.DeletedAt{}
return tx.Create(&v).Error
case "skills":
var v types.TenantSkillCatalogEntity
if err := tx.Where("tenant_id=? AND id=? AND instruction_only=true", t, source).First(&v).Error; err != nil {
return err
}
v.ID = target
v.CreatedBy = u
v.Visibility = "private"
v.BundleRef = ""
v.BundleSHA256 = ""
v.VersionSourceID = source
v.CreatedAt = time.Time{}
v.UpdatedAt = time.Time{}
v.DeletedAt = gorm.DeletedAt{}
return tx.Create(&v).Error
case "digital-humans":
body, _, err := s.resourceSnapshot(tx, ctx, kind, source)
if err != nil {
return err
}
var v humanVersion
if err = json.Unmarshal(body, &v); err != nil {
return err
}
h := types.DigitalHuman{ID: target, TenantID: t, OwnerID: u, Name: v.Name, Description: v.Description, Avatar: v.Avatar, ModelID: v.ModelID, Status: "draft", LatestVersion: 1, PrimaryAgentID: uuid.NewString(), Revision: 1}
return repository.NewDigitalHumanRepository(tx).Create(ctx, &h, v.Persona)
case "flows":
var v workbench.Resource
if err := tx.Where("tenant_id=? AND id=? AND kind='flows'", t, source).First(&v).Error; err != nil {
return err
}
v.ID = target
v.OwnerID = u
v.OwnerName = ""
v.Version = 1
v.CreatedAt = time.Time{}
v.UpdatedAt = time.Time{}
return tx.Create(&v).Error
case "knowledge":
if s.versionKnowledge == nil {
return errInvalid
}
return fmt.Errorf("%w: 知识库复制必须使用后台任务", errInvalid)
}
return errInvalid
}
func (s *Service) applyVersionResource(tx *gorm.DB, ctx context.Context, kind, id string, body types.JSON) error {
t, _ := identity(ctx)
switch kind {
case "agents":
var p agentVersion
if err := json.Unmarshal(body, &p); err != nil {
return err
}
var v types.CustomAgent
if err := tx.Where("tenant_id=? AND id=?", t, id).First(&v).Error; err != nil {
return err
}
var bindings int64
if err := tx.Model(&types.DigitalHumanBinding{}).Where("tenant_id=? AND agent_id=?", t, id).Count(&bindings).Error; err != nil {
return err
}
if bindings > 0 {
return fmt.Errorf("%w: 原 Agent 已绑定数字人,请审核数字人版本", errConflict)
}
v.Name = p.Name
v.Description = p.Description
v.Avatar = p.Avatar
v.Config = p.Config
return tx.Save(&v).Error
case "skills":
var p skillVersion
if err := json.Unmarshal(body, &p); err != nil {
return err
}
return tx.Model(&types.TenantSkillCatalogEntity{}).Where("tenant_id=? AND id=? AND instruction_only=true", t, id).Updates(map[string]any{"name": p.Name, "description": p.Description, "version": p.Version, "instructions": p.Instructions}).Error
case "digital-humans":
var p humanVersion
if err := json.Unmarshal(body, &p); err != nil {
return err
}
var h types.DigitalHuman
if err := tx.Where("tenant_id=? AND id=?", t, id).First(&h).Error; err != nil {
return err
}
r := repository.NewDigitalHumanRepository(tx)
scope := types.DigitalHumanScope{TenantID: t, OwnerID: h.OwnerID}
if err := r.Update(ctx, scope, id, h.Revision, p.Name, p.Description, p.Avatar, p.ModelID, p.Persona); err != nil {
return err
}
return r.Review(ctx, scope, id, h.LatestVersion+1, true, "全局个人版本审核通过")
case "flows":
var p flowVersion
if err := json.Unmarshal(body, &p); err != nil {
return err
}
var v workbench.Resource
if err := tx.Where("tenant_id=? AND id=? AND kind='flows'", t, id).First(&v).Error; err != nil {
return err
}
var jobs int64
if err := tx.Model(&workbench.Job{}).Where("tenant_id=? AND resource_id=? AND status IN ?", t, id, []string{"queued", "running"}).Count(&jobs).Error; err != nil {
return err
}
if jobs > 0 {
return errConflict
}
if err := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&workbench.Revision{ID: uuid.NewString(), TenantID: t, ResourceID: id, Version: v.Version, ActorID: v.OwnerID, Data: v.Data}).Error; err != nil {
return err
}
b, err := json.Marshal(p.Data)
if err != nil {
return err
}
if err = json.Unmarshal(b, &v.Data); err != nil {
return err
}
v.Name = p.Name
v.Version++
return tx.Save(&v).Error
case "knowledge":
if s.versionKnowledge == nil {
return errInvalid
}
return fmt.Errorf("%w: 请在知识库页面编辑内容,再提交不可变快照审核", errInvalid)
}
return errInvalid
}
func (s *Service) validateVersionReferences(tx *gorm.DB, ctx context.Context, kind string, body types.JSON, publishing bool) error {
t, u := identity(ctx)
check := func(kind, id string) error {
if id == "" {
return nil
}
if !publishing {
if err := versionPermission(ctx, kind, false); err != nil {
return err
}
}
v, err := sourceView(tx, t, kind, id)
if err != nil {
return err
}
if publishing && !v.Global {
return fmt.Errorf("%w: 全局版本不能引用未发布的 %s 资源", errInvalid, kind)
}
if !publishing && !administrator(ctx) && !accesspolicy.Readable(ctx, kind, id, v.OwnerID == u, v.Global) {
return errVersionDenied
}
return nil
}
model := func(id string) error {
if id == "" {
return nil
}
var count int64
if err := tx.Table("models").Where("tenant_id=? AND id=? AND deleted_at IS NULL", t, id).Count(&count).Error; err != nil {
return err
}
if count != 1 {
return fmt.Errorf("%w: 模型不存在或不属于当前空间", errInvalid)
}
return nil
}
switch kind {
case "agents":
var v agentVersion
if err := json.Unmarshal(body, &v); err != nil {
return err
}
for _, id := range v.Config.KnowledgeBases {
if err := check("knowledge", id); err != nil {
return err
}
}
for _, id := range v.Config.InstructionSkillIDs {
if err := check("skills", id); err != nil {
return err
}
}
for _, id := range []string{v.Config.ModelID, v.Config.RerankModelID, v.Config.VLMModelID, v.Config.ASRModelID, v.Config.QueryUnderstandModelID} {
if err := model(id); err != nil {
return err
}
}
for table, id := range map[string]string{"tenant_sandbox_configs": v.Config.SandboxConfigID, "web_search_providers": v.Config.WebSearchProviderID} {
if id == "" {
continue
}
var n int64
if err := tx.Table(table).Where("tenant_id=? AND id=? AND deleted_at IS NULL", t, id).Count(&n).Error; err != nil {
return err
}
if n != 1 {
return errInvalid
}
}
for _, id := range v.Config.MCPServices {
var n int64
if err := tx.Table("mcp_services").Where("tenant_id=? AND id=? AND deleted_at IS NULL", t, id).Count(&n).Error; err != nil {
return err
}
if n != 1 {
return errInvalid
}
}
case "digital-humans":
var v humanVersion
if err := json.Unmarshal(body, &v); err != nil {
return err
}
return model(v.ModelID)
case "flows":
var v flowVersion
if err := json.Unmarshal(body, &v); err != nil {
return err
}
for _, n := range v.Data.Nodes {
if err := check("agents", n.AgentID); err != nil {
return err
}
}
for _, id := range v.Data.KBIDs {
if err := check("knowledge", id); err != nil {
return err
}
}
for _, id := range v.Data.KnowledgeIDs {
var k types.Knowledge
if err := tx.Where("tenant_id=? AND id=?", t, id).First(&k).Error; err != nil {
return err
}
if err := check("knowledge", k.KnowledgeBaseID); err != nil {
return err
}
}
for kb := range v.Data.KnowledgeFolders {
if err := check("knowledge", kb); err != nil {
return err
}
}
for kb := range v.Data.KnowledgeTags {
if err := check("knowledge", kb); err != nil {
return err
}
}
}
return nil
}
func (s *Service) validateVersionBody(kind string, body types.JSON) error {
var base struct {
Name string `json:"name"`
Description string `json:"description"`
}
if json.Unmarshal(body, &base) != nil || strings.TrimSpace(base.Name) == "" || utf8.RuneCountInString(base.Name) > 100 || utf8.RuneCountInString(base.Description) > 3000 {
return errInvalid
}
switch kind {
case "agents":
var v agentVersion
if err := json.Unmarshal(body, &v); err != nil {
return errInvalid
}
if len(v.Config.SystemPrompt) > 64000 || len(v.Config.InstructionSkillIDs) > 32 || v.Config.Temperature < 0 || v.Config.Temperature > 2 {
return errInvalid
}
if v.Config.AgentMode != "" && v.Config.AgentMode != types.AgentModeSmartReasoning && v.Config.AgentMode != types.AgentModeQuickAnswer {
return errInvalid
}
if err := v.Config.QuestionSuggestions.Validate(); err != nil {
return fmt.Errorf("%w: %s", errInvalid, err)
}
case "skills":
var v skillVersion
if err := json.Unmarshal(body, &v); err != nil {
return errInvalid
}
if strings.TrimSpace(v.Instructions) == "" || utf8.RuneCountInString(v.Instructions) > 16000 || len(v.Version) > 64 {
return errInvalid
}
case "digital-humans":
var v humanVersion
if err := json.Unmarshal(body, &v); err != nil {
return errInvalid
}
keys := map[string]bool{}
for _, key := range strings.Fields("persona_name identity tone pace vocab taboo opening_habit emotion_style interaction expertise_border sample_excerpt writing_style narrative_style thinking_style audience_profile hook_patterns pain_point_hooks itch_point_hooks sales_logic cta_transition expression_prompt expression_rules") {
keys[key] = true
}
total := 0
for key, value := range v.Persona {
n := utf8.RuneCountInString(value)
total += n
if !keys[key] || n > 6000 {
return errInvalid
}
}
if total == 0 || total > 30000 {
return errInvalid
}
case "flows":
var v flowVersion
if err := json.Unmarshal(body, &v); err != nil {
return errInvalid
}
if len(v.Data.Nodes) > 1000 || len(v.Data.Edges) > 3000 {
return errInvalid
}
ids := map[string]bool{}
for _, n := range v.Data.Nodes {
if n.ID == "" || n.AgentID == "" || ids[n.ID] {
return errInvalid
}
ids[n.ID] = true
}
for _, e := range v.Data.Edges {
if !ids[e.Source] || !ids[e.Target] || e.Source == e.Target {
return errInvalid
}
}
}
return nil
}
+692
View File
@@ -0,0 +1,692 @@
package accesscontrol
import (
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"errors"
"fmt"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/accesspolicy"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// ResourceFork records provenance independently of native deletion. A personal
// copy can never shed its no-export restriction by hiding its source.
type ResourceFork struct {
ID string `json:"id" gorm:"primaryKey"`
TenantID uint64 `json:"tenant_id" gorm:"uniqueIndex:resource_fork_owner,priority:1"`
OwnerID string `json:"owner_id" gorm:"uniqueIndex:resource_fork_owner,priority:2"`
Kind string `json:"kind" gorm:"uniqueIndex:resource_fork_owner,priority:3"`
SourceID string `json:"source_id" gorm:"uniqueIndex:resource_fork_owner,priority:4"`
PersonalID string `json:"personal_id"`
Name string `json:"name"`
BaseFingerprint string `json:"base_fingerprint"`
BaseSnapshot types.JSON `json:"base_snapshot" gorm:"type:jsonb"`
NoExport bool `json:"no_export"`
Hidden bool `json:"hidden" gorm:"-"`
Status string `json:"status" gorm:"not null;default:'ready'"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
func (ResourceFork) TableName() string { return "workbench_resource_forks" }
type ResourceSubmission struct {
ID string `json:"id" gorm:"primaryKey"`
TenantID uint64 `json:"tenant_id" gorm:"index"`
OwnerID string `json:"owner_id"`
OwnerName string `json:"owner_name" gorm:"-"`
ForkID string `json:"fork_id" gorm:"index"`
Kind string `json:"kind"`
SourceID string `json:"source_id"`
PersonalID string `json:"personal_id"`
Name string `json:"name"`
Status string `json:"status"`
BaseFingerprint string `json:"base_fingerprint"`
PersonalFingerprint string `json:"personal_fingerprint"`
BaseSnapshot types.JSON `json:"base_snapshot" gorm:"type:jsonb"`
ProposedSnapshot types.JSON `json:"proposed_snapshot" gorm:"type:jsonb"`
Note string `json:"note"`
ReviewNote string `json:"review_note"`
ReviewerID string `json:"reviewer_id"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
SnapshotID string `json:"snapshot_id,omitempty"`
PublishedID string `json:"published_id,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
func (ResourceSubmission) TableName() string { return "workbench_resource_submissions" }
type ResourceHidden struct {
TenantID uint64 `json:"tenant_id" gorm:"primaryKey"`
OwnerID string `json:"owner_id" gorm:"primaryKey"`
Kind string `json:"kind" gorm:"primaryKey"`
ResourceID string `json:"resource_id" gorm:"primaryKey"`
CreatedAt time.Time `json:"created_at"`
}
func (ResourceHidden) TableName() string { return "workbench_resource_hidden" }
// Historical submissions remain read-only even when their personal copy was
// already deleted. Their source payload is retained only in the migration ledger.
type ResourceVersionHistory struct {
ID string `json:"id" gorm:"primaryKey"`
TenantID uint64 `json:"tenant_id"`
OwnerID string `json:"owner_id"`
OwnerName string `json:"owner_name" gorm:"-"`
Kind string `json:"kind"`
SourceID string `json:"source_id"`
Name string `json:"name"`
Status string `json:"status"`
ProposedSnapshot types.JSON `json:"proposed_snapshot" gorm:"type:jsonb"`
ReviewNote string `json:"review_note"`
CreatedAt time.Time `json:"created_at"`
ReviewedAt *time.Time `json:"reviewed_at,omitempty"`
}
func (ResourceVersionHistory) TableName() string { return "workbench_resource_version_history" }
func versionPermission(ctx context.Context, kind string, write bool) error {
t, u := identity(ctx)
if t == 0 || u == "" || types.IsSyntheticUserID(u) {
return fmt.Errorf("%w: 请使用个人账号", errVersionDenied)
}
if _, ok := resourceTables[kind]; !ok {
return errInvalid
}
module := kind
if kind == "skills" {
module = "agents"
}
action := ".read"
if write {
action = ".write"
}
if !accesspolicy.Allowed(ctx, module+action) || (write && !administrator(ctx) && !types.TenantRoleFromContext(ctx).HasPermission(types.TenantRoleContributor)) {
return fmt.Errorf("%w: 缺少 %s 权限", errVersionDenied, module+action)
}
return nil
}
var errVersionDenied = fmt.Errorf("没有资源操作权限")
func (s *Service) RegisterResourceVersions(r *gin.RouterGroup) {
r.GET("/resource-versions", s.ListResourceVersions)
r.GET("/resource-versions/catalog", s.VersionCatalog)
r.GET("/resource-versions/forks/:id", s.GetResourceVersion)
// Keep explicit retired routes so stale clients cannot create or approve
// copies. Historical records remain available for inspection.
r.POST("/resource-versions/:kind/:id/fork", retiredResourceVersionMutation)
r.PUT("/resource-versions/:kind/:id/hidden", retiredResourceVersionMutation)
r.POST("/resource-versions/forks/:id/submit", retiredResourceVersionMutation)
r.PUT("/resource-versions/forks/:id", retiredResourceVersionMutation)
r.POST("/resource-versions/forks/:id/rebase", retiredResourceVersionMutation)
r.POST("/resource-versions/submissions/:id/withdraw", retiredResourceVersionMutation)
r.POST("/resource-versions/submissions/:id/review", retiredResourceVersionMutation)
r.GET("/resource-versions/submissions/:id/knowledge", s.GetKnowledgeVersionReview)
r.POST("/resource-versions/jobs/:id/retry", retiredResourceVersionMutation)
}
func retiredResourceVersionMutation(c *gin.Context) {
fail(c, 410, "资源版本功能已停用;授权和全局资源可直接查看、使用,不支持创建副本或修改原资源")
}
func (s *Service) GetResourceVersion(c *gin.Context) {
ctx := c.Request.Context()
t, u := identity(ctx)
var f ResourceFork
q := s.db.WithContext(ctx).Where("tenant_id=? AND id=?", t, c.Param("id"))
if !administrator(ctx) {
q = q.Where("owner_id=?", u)
}
if err := q.First(&f).Error; err != nil {
dbResult(c, err, nil)
return
}
if err := versionPermission(ctx, f.Kind, false); err != nil {
versionResult(c, err, nil)
return
}
body, hash, err := s.resourceSnapshot(s.db.WithContext(ctx), ctx, f.Kind, f.PersonalID)
versionResult(c, err, gin.H{"fork": f, "snapshot": body, "fingerprint": hash})
}
func (s *Service) EditResourceVersion(c *gin.Context) {
ctx := c.Request.Context()
t, u := identity(ctx)
var req struct {
Fingerprint string `json:"fingerprint"`
Snapshot types.JSON `json:"snapshot"`
}
if c.ShouldBindJSON(&req) != nil || len(req.Snapshot) > 256*1024 || req.Fingerprint == "" {
fail(c, 400, "请刷新个人版本后再编辑")
return
}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var f ResourceFork
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND owner_id=? AND id=?", t, u, c.Param("id")).First(&f).Error; err != nil {
return err
}
if err := versionPermission(ctx, f.Kind, true); err != nil {
return err
}
r, err := sourceView(tx, t, f.Kind, f.PersonalID)
if err != nil {
return err
}
if r.OwnerID != u {
return errVersionDenied
}
_, hash, err := s.resourceSnapshot(tx, ctx, f.Kind, f.PersonalID)
if err != nil {
return err
}
if hash != req.Fingerprint {
return errConflict
}
if err = s.validateVersionReferences(tx, ctx, f.Kind, req.Snapshot, false); err != nil {
return err
}
if err = s.validateVersionBody(f.Kind, req.Snapshot); err != nil {
return err
}
if err = s.applyVersionResource(tx, ctx, f.Kind, f.PersonalID, req.Snapshot); err != nil {
return err
}
return s.audit(tx, ctx, "resource.personal_edited", f.ID, map[string]any{"kind": f.Kind})
})
versionResult(c, err, true)
}
func (s *Service) RebaseResourceVersion(c *gin.Context) {
ctx := c.Request.Context()
t, u := identity(ctx)
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var f ResourceFork
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND owner_id=? AND id=?", t, u, c.Param("id")).First(&f).Error; err != nil {
return err
}
if err := versionPermission(ctx, f.Kind, true); err != nil {
return err
}
var pending int64
if err := tx.Model(&ResourceSubmission{}).Where("tenant_id=? AND fork_id=? AND status IN ?", t, f.ID, []string{"pending", "snapshotting"}).Count(&pending).Error; err != nil {
return err
}
if pending > 0 {
return fmt.Errorf("%w: 请先撤回待审核版本", errConflict)
}
v, err := sourceView(tx, t, f.Kind, f.SourceID)
if err != nil {
return err
}
if !v.Global {
return errVersionDenied
}
body, hash, err := s.resourceSnapshot(tx, ctx, f.Kind, f.SourceID)
if err != nil {
return err
}
f.BaseSnapshot = body
f.BaseFingerprint = hash
if err = tx.Save(&f).Error; err != nil {
return err
}
return s.audit(tx, ctx, "resource.personal_rebased", f.ID, map[string]any{"kind": f.Kind})
})
versionResult(c, err, true)
}
func (s *Service) WithdrawResourceVersion(c *gin.Context) {
ctx := c.Request.Context()
t, u := identity(ctx)
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var r ResourceSubmission
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND owner_id=? AND id=?", t, u, c.Param("id")).First(&r).Error; err != nil {
return err
}
if err := versionPermission(ctx, r.Kind, true); err != nil {
return err
}
if r.Status != "pending" {
return errConflict
}
r.Status = "withdrawn"
if err := tx.Save(&r).Error; err != nil {
return err
}
return s.audit(tx, ctx, "resource.version_withdrawn", r.ID, map[string]any{"kind": r.Kind})
})
versionResult(c, err, true)
}
func fingerprint(value any) (types.JSON, string, error) {
b, err := json.Marshal(value)
if err != nil {
return nil, "", err
}
h := sha256.Sum256(b)
return types.JSON(b), hex.EncodeToString(h[:]), nil
}
func versionResult(c *gin.Context, err error, value any) {
if errors.Is(err, errVersionDenied) {
fail(c, 403, err.Error())
return
}
dbResult(c, err, value)
}
func sourceView(tx *gorm.DB, tenant uint64, kind, id string) (ResourceView, error) {
spec, ok := resourceTables[kind]
if !ok {
return ResourceView{}, errInvalid
}
visibility := "false AS global"
if kind == "knowledge" {
visibility = "true AS global"
}
if kind == "agents" || kind == "skills" {
visibility = "(visibility='global' AND enabled=true) AS global"
}
r := ResourceView{Kind: kind}
if err := tx.Table(spec.table).Clauses(clause.Locking{Strength: "UPDATE"}).Select("id,name,"+spec.owner+" AS owner_id,"+visibility).Where("tenant_id=? AND id=?", tenant, id).Where(spec.where).Take(&r).Error; err != nil {
return r, err
}
var p Publication
q := tx.Where("tenant_id=? AND kind=? AND resource_id=?", tenant, kind, id).Limit(1).Find(&p)
if q.Error != nil {
return r, q.Error
}
if q.RowsAffected > 0 {
r.Global = p.Global
}
return r, nil
}
func (s *Service) VersionCatalog(c *gin.Context) {
ctx := c.Request.Context()
t, u := identity(ctx)
if t == 0 || u == "" || types.IsSyntheticUserID(u) {
fail(c, 403, "请使用个人账号")
return
}
rows, err := resources(s.db.WithContext(ctx), t)
if err != nil {
dbResult(c, err, nil)
return
}
hidden := []ResourceHidden{}
if err = s.db.Where("tenant_id=? AND owner_id=?", t, u).Find(&hidden).Error; err != nil {
dbResult(c, err, nil)
return
}
h := map[string]bool{}
for _, v := range hidden {
h[v.Kind+":"+v.ResourceID] = true
}
out := []map[string]any{}
for _, v := range rows {
if versionPermission(ctx, v.Kind, false) != nil || !v.Global {
continue
}
out = append(out, map[string]any{"kind": v.Kind, "id": v.ID, "name": v.Name, "owner_name": v.OwnerName, "hidden": h[v.Kind+":"+v.ID], "can_fork": false})
}
data(c, out)
}
func (s *Service) ListResourceVersions(c *gin.Context) {
ctx := c.Request.Context()
t, u := identity(ctx)
if t == 0 || u == "" || types.IsSyntheticUserID(u) {
fail(c, 403, "请使用个人账号")
return
}
q := s.db.WithContext(ctx).Where("tenant_id=?", t)
if !administrator(ctx) {
q = q.Where("owner_id=?", u)
}
forks := []ResourceFork{}
submissions := []ResourceSubmission{}
history := []ResourceVersionHistory{}
if err := q.Session(&gorm.Session{}).Order("created_at DESC").Find(&forks).Error; err != nil {
dbResult(c, err, nil)
return
}
if err := q.Session(&gorm.Session{}).Order("created_at DESC").Find(&submissions).Error; err != nil {
dbResult(c, err, nil)
return
}
if err := q.Session(&gorm.Session{}).Order("created_at DESC").Find(&history).Error; err != nil {
dbResult(c, err, nil)
return
}
ff := []ResourceFork{}
ss := []ResourceSubmission{}
var hidden []ResourceHidden
if err := s.db.WithContext(ctx).Where("tenant_id=? AND owner_id=?", t, u).Find(&hidden).Error; err != nil {
dbResult(c, err, nil)
return
}
hiddenIDs := map[string]bool{}
for _, v := range hidden {
hiddenIDs[v.Kind+":"+v.ResourceID] = true
}
for _, v := range forks {
if versionPermission(ctx, v.Kind, false) == nil {
v.Hidden = hiddenIDs[v.Kind+":"+v.PersonalID]
ff = append(ff, v)
}
}
for _, v := range submissions {
if versionPermission(ctx, v.Kind, false) == nil {
ss = append(ss, v)
}
}
var names []struct{ ID, Username string }
if err := s.db.Table("users").Select("id,username").Where("id IN (SELECT user_id FROM tenant_members WHERE tenant_id=? AND deleted_at IS NULL)", t).Scan(&names).Error; err != nil {
dbResult(c, err, nil)
return
}
n := map[string]string{}
for _, v := range names {
n[v.ID] = v.Username
}
for i := range ss {
ss[i].OwnerName = n[ss[i].OwnerID]
}
hh := []ResourceVersionHistory{}
for _, v := range history {
if versionPermission(ctx, v.Kind, false) == nil {
v.OwnerName = n[v.OwnerID]
hh = append(hh, v)
}
}
jobs := []KnowledgeVersionJob{}
if versionPermission(ctx, "knowledge", false) == nil && s.versionKnowledge != nil {
if err := q.Session(&gorm.Session{}).Order("created_at DESC").Limit(100).Find(&jobs).Error; err != nil {
dbResult(c, err, nil)
return
}
}
data(c, gin.H{"forks": ff, "submissions": ss, "history": hh, "jobs": jobs})
}
func (s *Service) ForkResource(c *gin.Context) {
result, err := s.forkResource(c.Request.Context(), c.Param("kind"), c.Param("id"))
versionResult(c, err, result)
}
func (s *Service) forkResource(ctx context.Context, kind, id string) (*ResourceFork, error) {
if err := versionPermission(ctx, kind, true); err != nil {
return nil, err
}
t, u := identity(ctx)
out := &ResourceFork{}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if kind == "knowledge" && s.versionKnowledge != nil {
current, err := resolveKnowledgeVersion(tx, t, id)
if err != nil {
return err
}
id = current
}
source, err := sourceView(tx, t, kind, id)
if err != nil {
return err
}
if !source.Global {
return fmt.Errorf("%w: 只能创建全局资源的个人版本", errVersionDenied)
}
q := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND owner_id=? AND kind=? AND source_id=?", t, u, kind, id).Limit(1).Find(out)
if q.Error != nil {
return q.Error
}
previousForkID := ""
previousCreatedAt := out.CreatedAt
if q.RowsAffected > 0 {
// Knowledge copies can still be queued before their target exists.
if kind == "knowledge" {
return nil
}
if _, err := sourceView(tx, t, kind, out.PersonalID); err == nil {
return nil
} else if !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
var pending int64
if err := tx.Model(&ResourceSubmission{}).Where("tenant_id=? AND fork_id=? AND status='pending'", t, out.ID).Count(&pending).Error; err != nil {
return err
}
if pending > 0 {
return fmt.Errorf("%w: 旧个人版本已删除,请先撤回待审核版本后重新创建", errConflict)
}
previousForkID = out.ID
}
if kind == "knowledge" {
return s.reserveKnowledgeFork(tx, ctx, source, out)
}
body, hash, err := s.resourceSnapshot(tx, ctx, kind, id)
if err != nil {
return err
}
if err = s.validateVersionReferences(tx, ctx, kind, body, false); err != nil {
return err
}
personal := uuid.NewString()
if err = s.cloneVersionResource(tx, ctx, kind, id, personal); err != nil {
return err
}
*out = ResourceFork{ID: uuid.NewString(), TenantID: t, OwnerID: u, Kind: kind, SourceID: id, PersonalID: personal, Name: source.Name, BaseFingerprint: hash, BaseSnapshot: body, NoExport: true}
if previousForkID != "" {
out.ID = previousForkID
out.CreatedAt = previousCreatedAt
err = tx.Save(out).Error
} else {
err = tx.Create(out).Error
}
if err != nil {
return err
}
if err = tx.Create(&Publication{TenantID: t, Kind: kind, ResourceID: personal, Global: false}).Error; err != nil {
return err
}
return s.audit(tx, ctx, "resource.personal_created", out.ID, map[string]any{"kind": kind, "source_id": id, "personal_id": personal})
})
return out, err
}
func (s *Service) HideResource(c *gin.Context) {
ctx := c.Request.Context()
kind, id := c.Param("kind"), c.Param("id")
if err := versionPermission(ctx, kind, true); err != nil {
versionResult(c, err, nil)
return
}
var req struct {
Hidden *bool `json:"hidden"`
}
if c.ShouldBindJSON(&req) != nil || req.Hidden == nil {
fail(c, 400, "请选择隐藏或恢复")
return
}
t, u := identity(ctx)
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
r, err := sourceView(tx, t, kind, id)
if err != nil {
return err
}
var forks int64
if err = tx.Model(&ResourceFork{}).Where("tenant_id=? AND owner_id=? AND kind=? AND personal_id=?", t, u, kind, id).Count(&forks).Error; err != nil {
return err
}
if !r.Global && forks == 0 {
return errVersionDenied
}
row := ResourceHidden{TenantID: t, OwnerID: u, Kind: kind, ResourceID: id}
if *req.Hidden {
err = tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&row).Error
} else {
err = tx.Delete(&row).Error
}
if err != nil {
return err
}
return s.audit(tx, ctx, "resource.personal_visibility", id, map[string]any{"kind": kind, "hidden": *req.Hidden})
})
versionResult(c, err, true)
}
func (s *Service) SubmitResourceVersion(c *gin.Context) {
var req struct {
Note string `json:"note"`
}
if c.ShouldBindJSON(&req) != nil || len(req.Note) > 4000 {
fail(c, 400, "请填写不超过 4000 字节的修改说明")
return
}
row, err := s.submitResourceVersion(c.Request.Context(), c.Param("id"), req.Note)
versionResult(c, err, row)
}
func (s *Service) submitResourceVersion(ctx context.Context, id, note string) (*ResourceSubmission, error) {
t, u := identity(ctx)
out := &ResourceSubmission{}
err := s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var f ResourceFork
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND owner_id=? AND id=?", t, u, id).First(&f).Error; err != nil {
return err
}
if err := versionPermission(ctx, f.Kind, true); err != nil {
return err
}
if f.Kind == "knowledge" {
return s.reserveKnowledgeSubmission(tx, ctx, f, note, out)
}
r, err := sourceView(tx, t, f.Kind, f.SourceID)
if err != nil {
return err
}
if !r.Global {
return errVersionDenied
}
personal, err := sourceView(tx, t, f.Kind, f.PersonalID)
if err != nil {
return err
}
if personal.OwnerID != u {
return errVersionDenied
}
var pending int64
if err = tx.Model(&ResourceSubmission{}).Where("tenant_id=? AND fork_id=? AND status='pending'", t, f.ID).Count(&pending).Error; err != nil {
return err
}
if pending > 0 {
return fmt.Errorf("%w: 已有待审核版本", errConflict)
}
body, hash, err := s.resourceSnapshot(tx, ctx, f.Kind, f.PersonalID)
if err != nil {
return err
}
if err = s.validateVersionReferences(tx, ctx, f.Kind, body, true); err != nil {
return err
}
if err = s.validateVersionBody(f.Kind, body); err != nil {
return err
}
*out = ResourceSubmission{ID: uuid.NewString(), TenantID: t, OwnerID: u, ForkID: f.ID, Kind: f.Kind, SourceID: f.SourceID, PersonalID: f.PersonalID, Name: personal.Name, Status: "pending", BaseFingerprint: f.BaseFingerprint, PersonalFingerprint: hash, BaseSnapshot: f.BaseSnapshot, ProposedSnapshot: body, Note: strings.TrimSpace(note)}
if err = tx.Create(out).Error; err != nil {
return err
}
return s.audit(tx, ctx, "resource.version_submitted", out.ID, map[string]any{"kind": f.Kind, "source_id": f.SourceID})
})
return out, err
}
func (s *Service) ReviewResourceVersion(c *gin.Context) {
ctx := c.Request.Context()
if !administrator(ctx) {
fail(c, 403, "需要当前空间管理员权限")
return
}
var req struct {
Approve *bool `json:"approve"`
Note string `json:"note"`
}
if c.ShouldBindJSON(&req) != nil || req.Approve == nil || len(req.Note) > 4000 {
fail(c, 400, "请选择审核结果并限制说明长度")
return
}
err := s.reviewResourceVersion(ctx, c.Param("id"), *req.Approve, req.Note)
versionResult(c, err, true)
}
func (s *Service) reviewResourceVersion(ctx context.Context, id string, approve bool, note string) error {
if !administrator(ctx) {
return errVersionDenied
}
t, u := identity(ctx)
return s.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var r ResourceSubmission
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("tenant_id=? AND id=?", t, id).First(&r).Error; err != nil {
return err
}
if r.Status != "pending" {
return errConflict
}
if r.Kind == "knowledge" {
return s.reviewKnowledgeSubmission(tx, ctx, &r, approve, note)
}
if approve {
source, err := sourceView(tx, t, r.Kind, r.SourceID)
if err != nil {
return err
}
if !source.Global {
return fmt.Errorf("%w: 原资源已取消全局发布", errConflict)
}
_, hash, err := s.resourceSnapshot(tx, ctx, r.Kind, r.SourceID)
if err != nil {
return err
}
if hash != r.BaseFingerprint {
return fmt.Errorf("%w: 全局资源已有新版本,请重新创建个人版本后提交", errConflict)
}
if err = s.validateVersionReferences(tx, ctx, r.Kind, r.ProposedSnapshot, true); err != nil {
return err
}
if err = s.validateVersionBody(r.Kind, r.ProposedSnapshot); err != nil {
return err
}
if err = s.applyVersionResource(tx, ctx, r.Kind, r.SourceID, r.ProposedSnapshot); err != nil {
return err
}
r.Status = "approved"
body, newHash, err := s.resourceSnapshot(tx, ctx, r.Kind, r.SourceID)
if err != nil {
return err
}
if err = tx.Model(&ResourceFork{}).Where("tenant_id=? AND id=?", t, r.ForkID).Updates(map[string]any{"base_fingerprint": newHash, "base_snapshot": body}).Error; err != nil {
return err
}
} else {
r.Status = "rejected"
}
now := time.Now()
r.ReviewedAt = &now
r.ReviewerID = u
r.ReviewNote = strings.TrimSpace(note)
if err := tx.Save(&r).Error; err != nil {
return err
}
return s.audit(tx, ctx, "resource.version_"+r.Status, r.ID, map[string]any{"kind": r.Kind, "source_id": r.SourceID, "submitter": r.OwnerID})
})
}
@@ -0,0 +1,35 @@
package accesscontrol
import (
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
)
func TestRetiredResourceVersionMutationsNeverReachStorage(t *testing.T) {
// A nil database makes accidental calls to the former handlers fail loudly.
s := NewService(nil)
router := gin.New()
s.RegisterResourceVersions(router.Group("/api/v1"))
for _, tc := range []struct{ method, path string }{
{http.MethodPost, "/resource-versions/agents/source/fork"},
{http.MethodPut, "/resource-versions/agents/source/hidden"},
{http.MethodPost, "/resource-versions/forks/id/submit"},
{http.MethodPut, "/resource-versions/forks/id"},
{http.MethodPost, "/resource-versions/forks/id/rebase"},
{http.MethodPost, "/resource-versions/submissions/id/withdraw"},
{http.MethodPost, "/resource-versions/submissions/id/review"},
{http.MethodPost, "/resource-versions/jobs/id/retry"},
} {
for _, admin := range []bool{false, true} {
req := httptest.NewRequest(tc.method, "/api/v1"+tc.path, nil).WithContext(versionContext(1, "actor", admin))
res := httptest.NewRecorder()
router.ServeHTTP(res, req)
if res.Code != http.StatusGone {
t.Fatalf("%s %s admin=%v returned %d", tc.method, tc.path, admin, res.Code)
}
}
}
}
@@ -0,0 +1,335 @@
package accesscontrol
import (
"context"
"encoding/json"
"errors"
"path/filepath"
"testing"
"github.com/Tencent/WeKnora/internal/accesspolicy"
"github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/workbench"
"github.com/google/uuid"
"gorm.io/driver/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
)
func versionContext(tenant uint64, user string, admin bool) context.Context {
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, tenant)
ctx = context.WithValue(ctx, types.UserIDContextKey, user)
role := types.TenantRoleContributor
if admin {
role = types.TenantRoleAdmin
}
return context.WithValue(ctx, types.TenantRoleContextKey, role)
}
func versionDB(t *testing.T) *Service {
t.Helper()
db, err := gorm.Open(sqlite.Open(filepath.Join(t.TempDir(), "versions.db")), &gorm.Config{Logger: logger.Default.LogMode(logger.Silent)})
if err != nil {
t.Fatal(err)
}
if err = db.AutoMigrate(&Role{}, &Assignment{}, &Publication{}, &Grant{}, &ResourceFork{}, &ResourceSubmission{}, &ResourceHidden{}, &types.CustomAgent{}, &types.TenantSkillCatalogEntity{}, &types.DigitalHuman{}, &types.DigitalHumanVersion{}, &types.DigitalHumanBinding{}, &types.DigitalHumanJob{}, &workbench.Resource{}, &workbench.Revision{}, &workbench.Job{}, &types.AuditLog{}, &types.KnowledgeBase{}, &types.Knowledge{}); err != nil {
t.Fatal(err)
}
sql, _ := db.DB()
if err = db.Exec("CREATE UNIQUE INDEX uq_test_instruction_name ON tenant_skill_catalog(tenant_id,created_by,name,version_source_id) WHERE deleted_at IS NULL AND instruction_only = 1").Error; err != nil {
t.Fatal(err)
}
if err = db.Exec("CREATE TABLE users(id TEXT PRIMARY KEY, username TEXT, deleted_at DATETIME)").Error; err != nil {
t.Fatal(err)
}
t.Cleanup(func() { _ = sql.Close() })
return NewService(db)
}
func seedGlobal(t *testing.T, s *Service, kind string) string {
t.Helper()
id := uuid.NewString()
var err error
switch kind {
case "agents":
err = s.db.Create(&types.CustomAgent{ID: id, TenantID: 1, CreatedBy: "owner", Name: "原资源", Visibility: "global", Enabled: true, Config: types.CustomAgentConfig{SystemPrompt: "original"}}).Error
case "skills":
err = s.db.Create(&types.TenantSkillCatalogEntity{ID: id, TenantID: 1, CreatedBy: "owner", Name: "原资源", Visibility: "global", Enabled: true, InstructionOnly: true, Instructions: "original"}).Error
case "flows":
err = s.db.Create(&workbench.Resource{ID: id, TenantID: 1, OwnerID: "owner", Kind: "flows", Name: "原资源", Version: 1, Data: workbench.JSON{"nodes": []any{}, "edges": []any{}}}).Error
case "digital-humans":
err = repository.NewDigitalHumanRepository(s.db).Create(versionContext(1, "owner", true), &types.DigitalHuman{ID: id, TenantID: 1, OwnerID: "owner", Name: "原资源", Status: "draft", LatestVersion: 1, Revision: 1, PrimaryAgentID: uuid.NewString()}, map[string]string{"tone": "original"})
}
if err != nil {
t.Fatal(err)
}
if err = s.db.Create(&Publication{TenantID: 1, Kind: kind, ResourceID: id, Global: true}).Error; err != nil {
t.Fatal(err)
}
return id
}
func TestPersonalVersionsPreserveOriginalAndApproveImmutableSubmission(t *testing.T) {
for _, kind := range []string{"agents", "skills", "flows", "digital-humans"} {
t.Run(kind, func(t *testing.T) {
s := versionDB(t)
id := seedGlobal(t, s, kind)
ctx := versionContext(1, "alice", false)
admin := versionContext(1, "owner", true)
f, err := s.forkResource(ctx, kind, id)
if err != nil {
t.Fatal(err)
}
if !f.NoExport || f.PersonalID == id || f.OwnerID != "alice" {
t.Fatal("fork provenance missing")
}
again, err := s.forkResource(ctx, kind, id)
if err != nil || again.ID != f.ID {
t.Fatal("fork must be idempotent", err)
}
bob, err := s.forkResource(versionContext(1, "bob", false), kind, id)
if err != nil || bob.PersonalID == f.PersonalID {
t.Fatal("personal copies shared", err)
}
body, _, err := s.resourceSnapshot(s.db, ctx, kind, f.PersonalID)
if err != nil {
t.Fatal(err)
}
var p map[string]any
_ = json.Unmarshal(body, &p)
p["name"] = "个人修改"
body, _ = json.Marshal(p)
if err = s.applyVersionResource(s.db, ctx, kind, f.PersonalID, body); err != nil {
t.Fatal(err)
}
orig, _, _ := s.resourceSnapshot(s.db, ctx, kind, id)
var original map[string]any
_ = json.Unmarshal(orig, &original)
if original["name"] != "原资源" {
t.Fatal("fork changed global resource")
}
r, err := s.submitResourceVersion(ctx, f.ID, "修改说明")
if err != nil {
t.Fatal(err)
}
if _, err = s.submitResourceVersion(ctx, f.ID, "duplicate"); !errors.Is(err, errConflict) {
t.Fatal("duplicate pending submission accepted", err)
}
p["name"] = "提交后的私有修改"
body, _ = json.Marshal(p)
if err = s.applyVersionResource(s.db, ctx, kind, f.PersonalID, body); err != nil {
t.Fatal(err)
}
if err = s.reviewResourceVersion(ctx, r.ID, true, ""); !errors.Is(err, errVersionDenied) {
t.Fatal("ordinary user approved", err)
}
if err = s.reviewResourceVersion(admin, r.ID, true, "通过"); err != nil {
t.Fatal(err)
}
updated, _, _ := s.resourceSnapshot(s.db, ctx, kind, id)
_ = json.Unmarshal(updated, &original)
if original["name"] != "个人修改" {
t.Fatal("approval did not use immutable snapshot", string(updated))
}
if err = s.reviewResourceVersion(admin, r.ID, true, ""); !errors.Is(err, errConflict) {
t.Fatal("replayed approval accepted", err)
}
})
}
}
func TestSkillPersonalVersionPreservesNameForOwnerAndExistingImport(t *testing.T) {
s := versionDB(t)
id := seedGlobal(t, s, "skills")
if err := s.db.Create(&types.TenantSkillCatalogEntity{ID: uuid.NewString(), TenantID: 1, CreatedBy: "alice", Name: "原资源", InstructionOnly: true, Instructions: "independent import"}).Error; err != nil {
t.Fatal(err)
}
for _, user := range []string{"owner", "alice"} {
ctx := versionContext(1, user, user == "owner")
fork, err := s.forkResource(ctx, "skills", id)
if err != nil {
t.Fatal(err)
}
var personal types.TenantSkillCatalogEntity
if err = s.db.First(&personal, "id=?", fork.PersonalID).Error; err != nil {
t.Fatal(err)
}
if personal.Name != "原资源" || personal.VersionSourceID != id {
t.Fatal("personal version lost original name or source")
}
}
}
func TestDeletedSkillPersonalVersionCanBeRecreatedWithoutLosingHistory(t *testing.T) {
s := versionDB(t)
id := seedGlobal(t, s, "skills")
ctx := versionContext(1, "owner", true)
fork, err := s.forkResource(ctx, "skills", id)
if err != nil {
t.Fatal(err)
}
submission, err := s.submitResourceVersion(ctx, fork.ID, "history")
if err != nil {
t.Fatal(err)
}
if err = s.db.Where("id=?", fork.PersonalID).Delete(&types.TenantSkillCatalogEntity{}).Error; err != nil {
t.Fatal(err)
}
if _, err = s.forkResource(ctx, "skills", id); !errors.Is(err, errConflict) {
t.Fatal("pending submission must block recreation", err)
}
if err = s.reviewResourceVersion(ctx, submission.ID, false, "withdraw old version"); err != nil {
t.Fatal(err)
}
recreated, err := s.forkResource(ctx, "skills", id)
if err != nil {
t.Fatal(err)
}
if recreated.PersonalID == fork.PersonalID || recreated.ID != fork.ID {
t.Fatal("recreation must keep provenance and create a fresh resource")
}
if recreated.CreatedAt.IsZero() || !recreated.CreatedAt.Equal(fork.CreatedAt) {
t.Fatal("recreation must preserve the original provenance timestamp")
}
var saved ResourceSubmission
if err = s.db.First(&saved, "id=?", submission.ID).Error; err != nil {
t.Fatal(err)
}
if saved.PersonalID != fork.PersonalID || saved.Status != "rejected" {
t.Fatal("recreation changed historical submission")
}
}
func TestPersonalVersionsRejectConflictsAndForeignScope(t *testing.T) {
s := versionDB(t)
id := seedGlobal(t, s, "skills")
ctx := versionContext(1, "alice", false)
if _, err := s.forkResource(versionContext(2, "alice", false), "skills", id); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatal("cross tenant fork accepted", err)
}
denied := accesspolicy.WithSnapshot(ctx, &accesspolicy.Snapshot{Restricted: true, Permissions: map[string]bool{"agents.read": true}})
if _, err := s.forkResource(denied, "skills", id); err == nil {
t.Fatal("denied module fork accepted")
}
f, err := s.forkResource(ctx, "skills", id)
if err != nil {
t.Fatal(err)
}
if _, err = s.submitResourceVersion(versionContext(1, "bob", false), f.ID, ""); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatal("other user's fork submitted", err)
}
r, err := s.submitResourceVersion(ctx, f.ID, "")
if err != nil {
t.Fatal(err)
}
s.db.Model(&types.TenantSkillCatalogEntity{}).Where("id=?", id).Update("instructions", "new global")
if err = s.reviewResourceVersion(versionContext(1, "owner", true), r.ID, true, ""); !errors.Is(err, errConflict) {
t.Fatal("stale global overwritten", err)
}
var stored types.TenantSkillCatalogEntity
s.db.First(&stored, "id=?", id)
if stored.Instructions != "new global" {
t.Fatal("conflict changed source")
}
if err = s.reviewResourceVersion(versionContext(2, "admin", true), r.ID, false, ""); !errors.Is(err, gorm.ErrRecordNotFound) {
t.Fatal("cross tenant review accepted", err)
}
if err = s.reviewResourceVersion(versionContext(1, "owner", true), r.ID, false, "已有新版本"); err != nil {
t.Fatal(err)
}
}
func TestPersonalVersionsRefusePrivateGlobalDependencies(t *testing.T) {
s := versionDB(t)
source := seedGlobal(t, s, "agents")
ctx := versionContext(1, "alice", false)
privateSkill := types.TenantSkillCatalogEntity{ID: uuid.NewString(), TenantID: 1, CreatedBy: "alice", Name: "private", Visibility: "private", InstructionOnly: true, Enabled: true, Instructions: "private"}
if err := s.db.Create(&privateSkill).Error; err != nil {
t.Fatal(err)
}
f, err := s.forkResource(ctx, "agents", source)
if err != nil {
t.Fatal(err)
}
var a types.CustomAgent
s.db.First(&a, "id=?", f.PersonalID)
a.Config.InstructionSkillIDs = []string{privateSkill.ID}
s.db.Save(&a)
if _, err = s.submitResourceVersion(ctx, f.ID, ""); !errors.Is(err, errInvalid) {
t.Fatal("private dependency published", err)
}
}
func TestPersonalAccessHidingAndExportProvenance(t *testing.T) {
s := versionDB(t)
source := seedGlobal(t, s, "skills")
ctx := versionContext(1, "alice", false)
f, err := s.forkResource(ctx, "skills", source)
if err != nil {
t.Fatal(err)
}
if err = s.db.Create(&ResourceHidden{TenantID: 1, OwnerID: "alice", Kind: "skills", ResourceID: source}).Error; err != nil {
t.Fatal(err)
}
for _, user := range []string{"alice", "bob"} {
v := &accesspolicy.Snapshot{Publications: map[string]bool{"skills:" + source: true}}
if err = s.populatePersonalAccess(versionContext(1, user, false), v); err != nil {
t.Fatal(err)
}
if v.Hidden["skills:"+source] != (user == "alice") {
t.Fatal("hide crossed users")
}
if !v.NoExport["skills:"+f.PersonalID] {
t.Fatal("fork lost provenance")
}
if accesspolicy.Readable(accesspolicy.WithSnapshot(ctx, v), "skills", source, false, true) == (user == "alice") {
t.Fatal("hidden shared resource readable")
}
}
}
func TestHiddenDigitalHumanAlsoHidesItsDefaultAgent(t *testing.T) {
s := versionDB(t)
id := seedGlobal(t, s, "digital-humans")
ctx := versionContext(1, "alice", false)
f, err := s.forkResource(ctx, "digital-humans", id)
if err != nil {
t.Fatal(err)
}
var human types.DigitalHuman
if err = s.db.First(&human, "id=?", f.PersonalID).Error; err != nil {
t.Fatal(err)
}
if err = s.db.Create(&ResourceHidden{TenantID: 1, OwnerID: "alice", Kind: "digital-humans", ResourceID: f.PersonalID}).Error; err != nil {
t.Fatal(err)
}
snapshot := &accesspolicy.Snapshot{}
if err = s.populatePersonalAccess(ctx, snapshot); err != nil {
t.Fatal(err)
}
ctx = accesspolicy.WithSnapshot(ctx, snapshot)
if accesspolicy.Readable(ctx, "agents", human.PrimaryAgentID, true, false) {
t.Fatal("hidden digital human default Agent remains visible")
}
_, err = repository.NewDigitalHumanRepository(s.db).Get(ctx, types.DigitalHumanScope{TenantID: 1, OwnerID: "alice"}, f.PersonalID)
if !errors.Is(err, types.ErrDigitalHumanNotFound) {
t.Fatal("hidden owned digital human remains readable", err)
}
if _, err = repository.NewDigitalHumanRepository(s.db).Get(versionContext(1, "owner", true), types.DigitalHumanScope{TenantID: 1, OwnerID: "owner", ReadAll: true}, f.PersonalID); err != nil {
t.Fatal("personal hiding removed administrator access", err)
}
}
func TestBoundAgentForkRequiresDigitalHumanVersion(t *testing.T) {
s := versionDB(t)
id := seedGlobal(t, s, "digital-humans")
var human types.DigitalHuman
if err := s.db.First(&human, "id=?", id).Error; err != nil {
t.Fatal(err)
}
if err := s.db.Create(&Publication{TenantID: 1, Kind: "agents", ResourceID: human.PrimaryAgentID, Global: true}).Error; err != nil {
t.Fatal(err)
}
if _, err := s.forkResource(versionContext(1, "alice", false), "agents", human.PrimaryAgentID); !errors.Is(err, errInvalid) {
t.Fatal("bound Agent cloned without its digital human relationship", err)
}
}
+631
View File
@@ -0,0 +1,631 @@
package accesscontrol
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/mail"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/accesspolicy"
"github.com/Tencent/WeKnora/internal/application/repository"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
type Service struct {
db *gorm.DB
versionKnowledge KnowledgeVersionAdapter
}
func NewService(db *gorm.DB) *Service { return &Service{db: db} }
func administrator(ctx context.Context) bool {
return types.IsSystemAdminFromContext(ctx) || types.TenantRoleFromContext(ctx).HasPermission(types.TenantRoleAdmin)
}
func identity(ctx context.Context) (uint64, string) {
t, _ := types.TenantIDFromContext(ctx)
u, _ := types.UserIDFromContext(ctx)
return t, u
}
func data(c *gin.Context, v any) { c.JSON(200, gin.H{"success": true, "data": v}) }
func fail(c *gin.Context, status int, message string) {
c.AbortWithStatusJSON(status, gin.H{"success": false, "message": message})
}
func dbResult(c *gin.Context, err error, v any) {
if err == nil {
data(c, v)
return
}
if errors.Is(err, gorm.ErrRecordNotFound) {
fail(c, 404, "记录不存在或不属于当前空间")
return
}
if errors.Is(err, errInvalid) {
fail(c, 400, err.Error())
return
}
if errors.Is(err, errConflict) {
fail(c, 409, err.Error())
return
}
fail(c, 500, "访问控制操作失败,请检查系统日志")
}
var errInvalid = errors.New("请求参数无效")
var errConflict = errors.New("记录已更改或仍被使用,请刷新后重试")
func (s *Service) Register(r *gin.RouterGroup) {
r.GET("/access/me", s.Me)
a := r.Group("/access", func(c *gin.Context) {
t, u := identity(c.Request.Context())
if t == 0 || u == "" || types.IsSyntheticUserID(u) || !administrator(c.Request.Context()) {
fail(c, 403, "需要当前空间管理员权限")
return
}
c.Next()
})
a.GET("/catalog", func(c *gin.Context) { data(c, Catalog) })
a.GET("/roles", s.ListRoles)
a.POST("/roles", s.SaveRole)
a.PUT("/roles/:id", s.SaveRole)
a.DELETE("/roles/:id", s.DeleteRole)
a.GET("/users", s.ListUsers)
a.POST("/users", s.CreateUser)
a.PUT("/users/:id", s.UpdateUser)
a.DELETE("/users/:id", s.DeleteUser)
a.GET("/resources", s.ListResources)
a.GET("/grants", s.ListGrants)
a.POST("/grants", s.UpdateGrants)
}
func (s *Service) Snapshot(ctx context.Context) (*accesspolicy.Snapshot, error) {
t, u := identity(ctx)
v := &accesspolicy.Snapshot{Permissions: map[string]bool{}, Publications: map[string]bool{}, Grants: map[string]bool{}}
if t == 0 || u == "" || types.IsSyntheticUserID(u) {
return v, nil
}
if !administrator(ctx) {
var assignment Assignment
r := s.db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", t, u).Limit(1).Find(&assignment)
if r.Error != nil {
return nil, r.Error
}
if r.RowsAffected > 0 {
v.Restricted = true
var role Role
if err := s.db.WithContext(ctx).Where("id = ? AND tenant_id = ?", assignment.RoleID, t).First(&role).Error; err != nil {
return nil, err
}
for _, p := range effectiveRolePermissions(role) {
v.Permissions[p] = true
}
}
}
var publications []Publication
if err := s.db.WithContext(ctx).Where("tenant_id = ?", t).Find(&publications).Error; err != nil {
return nil, err
}
for _, p := range publications {
v.Publications[p.Kind+":"+p.ResourceID] = p.Global
}
var grants []Grant
if err := s.db.WithContext(ctx).Where("tenant_id = ? AND user_id = ?", t, u).Find(&grants).Error; err != nil {
return nil, err
}
for _, g := range grants {
v.Grants[g.Kind+":"+g.ResourceID] = true
}
if err := s.populatePersonalAccess(ctx, v); err != nil {
return nil, err
}
var humans []struct{ ID, PrimaryAgentID string }
if err := s.db.WithContext(ctx).Table("digital_humans").Select("id,primary_agent_id").Where("tenant_id=? AND deleted_at IS NULL", t).Scan(&humans).Error; err != nil {
return nil, err
}
for _, h := range humans {
if h.PrimaryAgentID == "" {
continue
}
global, managed := v.Publications["digital-humans:"+h.ID]
if managed && global {
v.Grants["agents:"+h.PrimaryAgentID] = true
}
if v.Grants["digital-humans:"+h.ID] {
v.Grants["agents:"+h.PrimaryAgentID] = true
}
}
return v, nil
}
func (s *Service) Me(c *gin.Context) {
v, err := s.Snapshot(c.Request.Context())
if err != nil {
dbResult(c, err, nil)
return
}
data(c, gin.H{"permissions": visiblePermissions(c.Request.Context(), v), "restricted": v.Restricted, "administrator": administrator(c.Request.Context())})
}
func visiblePermissions(ctx context.Context, snapshot *accesspolicy.Snapshot) []string {
permissions := []string{}
admin := administrator(ctx)
contributor := admin || types.TenantRoleFromContext(ctx).HasPermission(types.TenantRoleContributor)
for _, m := range Catalog {
for _, p := range m.Permissions {
if snapshot.Restricted && !snapshot.Permissions[p.ID] {
continue
}
if m.AdminOnly && !admin {
continue
}
if !strings.HasSuffix(p.ID, ".read") && p.ID != "content.deliver" && m.ID != "chat" && !contributor {
continue
}
permissions = append(permissions, p.ID)
}
}
return permissions
}
func (s *Service) audit(tx *gorm.DB, ctx context.Context, action, target string, details map[string]any) error {
t, u := identity(ctx)
body, err := json.Marshal(details)
if err != nil {
return err
}
return tx.Create(&types.AuditLog{TenantID: t, ActorUserID: u, ActorRole: string(types.TenantRoleFromContext(ctx)), Action: types.AuditAction(action), TargetType: "access", TargetID: target, Outcome: types.AuditOutcomeSuccess, Details: types.JSON(body), CreatedAt: time.Now()}).Error
}
func (s *Service) ListRoles(c *gin.Context) {
t, _ := identity(c.Request.Context())
rows := []Role{}
err := s.db.Where("tenant_id = ?", t).Order("name,id").Find(&rows).Error
for i := range rows {
rows[i].Permissions = effectiveRolePermissions(rows[i])
}
dbResult(c, err, rows)
}
func (s *Service) SaveRole(c *gin.Context) {
var req struct {
Name string `json:"name"`
Description string `json:"description"`
Permissions []string `json:"permissions"`
Version int `json:"version"`
}
if c.ShouldBindJSON(&req) != nil || strings.TrimSpace(req.Name) == "" || len(req.Name) > 100 || len(req.Description) > 2000 || !validPermissions(req.Permissions) {
fail(c, 400, "请填写角色名称,操作权限必须同时包含查看权限")
return
}
t, _ := identity(c.Request.Context())
id := c.Param("id")
now := time.Now()
role := Role{ID: id, TenantID: t, Name: strings.TrimSpace(req.Name), Description: strings.TrimSpace(req.Description), Permissions: req.Permissions, PermissionVersion: currentPermissionVersion, Version: req.Version + 1, UpdatedAt: now}
err := s.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
var count int64
if err := tx.Model(&Role{}).Where("tenant_id = ? AND name = ? AND id <> ?", t, role.Name, id).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return fmt.Errorf("%w: 角色名称已存在", errConflict)
}
if id == "" {
role.ID = uuid.NewString()
role.Version = 1
role.CreatedAt = now
if err := tx.Create(&role).Error; err != nil {
return err
}
} else {
q := tx.Model(&Role{}).Where("id = ? AND tenant_id = ? AND version = ?", id, t, req.Version).Updates(map[string]any{"name": role.Name, "description": role.Description, "permissions": role.Permissions, "permission_version": currentPermissionVersion, "version": role.Version, "updated_at": now})
if q.Error != nil {
return q.Error
}
if q.RowsAffected != 1 {
return errConflict
}
}
return s.audit(tx, c.Request.Context(), "access.role_saved", role.ID, map[string]any{"name": role.Name, "permissions": req.Permissions})
})
dbResult(c, err, role)
}
func (s *Service) DeleteRole(c *gin.Context) {
t, _ := identity(c.Request.Context())
id := c.Param("id")
err := s.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
var role Role
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND tenant_id = ?", id, t).First(&role).Error; err != nil {
return err
}
var count int64
if err := tx.Model(&Assignment{}).Where("role_id = ? AND tenant_id = ?", id, t).Count(&count).Error; err != nil {
return err
}
if count > 0 {
return fmt.Errorf("%w: 请先更换使用该角色的用户", errConflict)
}
if err := tx.Delete(&role).Error; err != nil {
return err
}
return s.audit(tx, c.Request.Context(), "access.role_deleted", id, map[string]any{"name": role.Name})
})
dbResult(c, err, true)
}
type UserView struct {
ID string `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
Status string `json:"status"`
CustomRoleID string `json:"custom_role_id"`
IsSystemAdmin bool `json:"is_system_admin"`
IsActive bool `json:"is_active"`
}
func (s *Service) ListUsers(c *gin.Context) {
t, _ := identity(c.Request.Context())
rows := []UserView{}
err := repository.TenantUsersQuery(s.db.WithContext(c.Request.Context()), t).
Select("u.id,u.username,u.email,u.is_system_admin,u.is_active,m.role,m.status,COALESCE(a.role_id,'') AS custom_role_id").
Joins("LEFT JOIN workbench_access_assignments a ON a.user_id=u.id AND a.tenant_id=m.tenant_id").
Order("u.username,u.id").Scan(&rows).Error
dbResult(c, err, rows)
}
type userRequest struct {
Username string `json:"username"`
Email string `json:"email"`
Password string `json:"password"`
Role string `json:"role"`
CustomRoleID string `json:"custom_role_id"`
Status string `json:"status"`
}
func assign(tx *gorm.DB, t uint64, u, id string) error {
if id == "" {
return tx.Where("tenant_id = ? AND user_id = ?", t, u).Delete(&Assignment{}).Error
}
var role Role
if err := tx.Clauses(clause.Locking{Strength: "SHARE"}).Where("tenant_id = ? AND id = ?", t, id).First(&role).Error; err != nil {
return err
}
return tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "tenant_id"}, {Name: "user_id"}}, DoUpdates: clause.AssignmentColumns([]string{"role_id"})}).Create(&Assignment{TenantID: t, UserID: u, RoleID: id}).Error
}
func validUserRequest(req userRequest, create bool) bool {
if req.Role != "contributor" && req.Role != "viewer" {
return false
}
if req.Status != "active" && req.Status != "suspended" {
return false
}
if req.Password != "" && secutils.ValidatePasswordPolicy(req.Password, false) != nil {
return false
}
if req.Email != "" {
address, err := mail.ParseAddress(req.Email)
if err != nil || address.Address != req.Email || len(req.Email) > 255 {
return false
}
}
if len(req.Username) > 100 || (req.Username != "" && strings.TrimSpace(req.Username) == "") {
return false
}
if create {
address, err := mail.ParseAddress(req.Email)
return err == nil && address.Address == req.Email && len(req.Email) <= 255 && strings.TrimSpace(req.Username) != "" && len(req.Username) <= 100 && req.Password != ""
}
return true
}
func (s *Service) CreateUser(c *gin.Context) {
var req userRequest
if c.ShouldBindJSON(&req) != nil || !validUserRequest(req, true) {
fail(c, 400, "请填写用户名、有效邮箱、3 至 32 个字符的密码(最多 72 字节)及普通用户角色")
return
}
t, _ := identity(c.Request.Context())
hash, err := bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
dbResult(c, err, nil)
return
}
u := types.User{ID: uuid.NewString(), TenantID: t, Username: strings.TrimSpace(req.Username), Email: strings.ToLower(strings.TrimSpace(req.Email)), PasswordHash: string(hash), IsActive: true, CreatedAt: time.Now(), UpdatedAt: time.Now()}
err = s.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
var count int64
if e := tx.Unscoped().Model(&types.User{}).Where("LOWER(email)=? OR username=?", u.Email, u.Username).Count(&count).Error; e != nil {
return e
}
if count > 0 {
return fmt.Errorf("%w: 用户名或邮箱已被使用", errConflict)
}
if e := tx.Create(&u).Error; e != nil {
return e
}
if e := tx.Create(&types.TenantMember{TenantID: t, UserID: u.ID, Role: types.TenantRole(req.Role), Status: types.TenantMemberStatus(req.Status), JoinedAt: time.Now()}).Error; e != nil {
return e
}
if e := assign(tx, t, u.ID, req.CustomRoleID); e != nil {
return e
}
return s.audit(tx, c.Request.Context(), "access.user_created", u.ID, map[string]any{"username": u.Username, "role": req.Role})
})
dbResult(c, err, gin.H{"id": u.ID})
}
func manageableUser(tx *gorm.DB, t uint64, actor, id string) (*types.User, *types.TenantMember, error) {
if id == actor {
return nil, nil, fmt.Errorf("%w: 不能修改或删除当前登录账号", errInvalid)
}
var u types.User
var m types.TenantMember
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).First(&u, "id = ?", id).Error; err != nil {
return nil, nil, err
}
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Where("user_id = ? AND tenant_id = ?", id, t).First(&m).Error; err != nil {
return nil, nil, err
}
if u.IsSystemAdmin || u.CanAccessAllTenants || m.Role.HasPermission(types.TenantRoleAdmin) {
return nil, nil, fmt.Errorf("%w: 管理员账号不能在普通用户管理中修改", errInvalid)
}
return &u, &m, nil
}
func (s *Service) UpdateUser(c *gin.Context) {
var req userRequest
if c.ShouldBindJSON(&req) != nil || !validUserRequest(req, false) {
fail(c, 400, "请选择有效角色和状态;新密码为 3 至 32 个字符(最多 72 字节)")
return
}
t, actor := identity(c.Request.Context())
id := c.Param("id")
var hash []byte
var err error
if req.Password != "" {
hash, err = bcrypt.GenerateFromPassword([]byte(req.Password), bcrypt.DefaultCost)
if err != nil {
dbResult(c, err, nil)
return
}
}
err = s.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
u, m, e := manageableUser(tx, t, actor, id)
if e != nil {
return e
}
changes := map[string]any{}
if req.Username != "" && strings.TrimSpace(req.Username) != u.Username {
changes["username"] = strings.TrimSpace(req.Username)
}
if req.Email != "" && strings.ToLower(strings.TrimSpace(req.Email)) != u.Email {
changes["email"] = strings.ToLower(strings.TrimSpace(req.Email))
}
if len(hash) > 0 {
changes["password_hash"] = string(hash)
}
if req.Status == "active" && !u.IsActive {
if len(hash) == 0 {
return fmt.Errorf("%w: 启用迁移或停用账号时请同时设置新密码", errInvalid)
}
changes["is_active"] = true
}
if len(changes) > 0 {
if u.TenantID != t {
return fmt.Errorf("%w: 只能修改本空间创建账号的登录信息", errInvalid)
}
username, email := u.Username, u.Email
if v, ok := changes["username"].(string); ok {
username = v
}
if v, ok := changes["email"].(string); ok {
email = v
}
var count int64
if e = tx.Unscoped().Model(&types.User{}).Where("id <> ? AND (LOWER(email) = ? OR username = ?)", id, strings.ToLower(email), username).Count(&count).Error; e != nil {
return e
}
if count > 0 {
return fmt.Errorf("%w: 用户名或邮箱已被使用", errConflict)
}
if e = tx.Model(u).Updates(changes).Error; e != nil {
return e
}
}
if e = tx.Model(m).Updates(map[string]any{"role": req.Role, "status": req.Status}).Error; e != nil {
return e
}
if e = assign(tx, t, id, req.CustomRoleID); e != nil {
return e
}
if len(changes) > 0 || req.Status == "suspended" {
if e = tx.Model(&types.AuthToken{}).Where("user_id = ?", id).Update("is_revoked", true).Error; e != nil {
return e
}
}
return s.audit(tx, c.Request.Context(), "access.user_updated", id, map[string]any{"role": req.Role, "status": req.Status, "custom_role_id": req.CustomRoleID, "password_reset": len(hash) > 0})
})
dbResult(c, err, true)
}
func (s *Service) DeleteUser(c *gin.Context) {
t, actor := identity(c.Request.Context())
id := c.Param("id")
err := s.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
u, _, e := manageableUser(tx, t, actor, id)
if e != nil {
return e
}
if u.TenantID != t {
return fmt.Errorf("%w: 不能删除属于其他空间的账号", errInvalid)
}
var count int64
if e = tx.Model(&types.TenantMember{}).Where("user_id = ? AND tenant_id <> ?", id, t).Count(&count).Error; e != nil {
return e
}
if count > 0 {
return fmt.Errorf("%w: 此用户仍属于其他空间,不能删除整个账号", errConflict)
}
if e = tx.Model(&types.AuthToken{}).Where("user_id = ?", id).Update("is_revoked", true).Error; e != nil {
return e
}
if e = tx.Where("tenant_id = ? AND user_id = ?", t, id).Delete(&Assignment{}).Error; e != nil {
return e
}
if e = tx.Where("tenant_id = ? AND user_id = ?", t, id).Delete(&Grant{}).Error; e != nil {
return e
}
if e = tx.Where("tenant_id = ? AND user_id = ?", t, id).Delete(&types.TenantMember{}).Error; e != nil {
return e
}
if e = tx.Delete(u).Error; e != nil {
return e
}
return s.audit(tx, c.Request.Context(), "access.user_deleted", id, map[string]any{"username": u.Username})
})
dbResult(c, err, true)
}
// Native owners keep their edit rights. Grants provide read/use, never ownership.
type ResourceView struct {
Kind string `json:"kind"`
ID string `json:"id"`
Name string `json:"name"`
OwnerID string `json:"owner_id"`
OwnerName string `json:"owner_name"`
Global bool `json:"global"`
}
type resourceTable struct{ table, owner, where string }
var resourceTables = map[string]resourceTable{
"agents": {"custom_agents", "created_by", "deleted_at IS NULL"},
"skills": {"tenant_skill_catalog", "created_by", "instruction_only = true AND deleted_at IS NULL"},
"knowledge": {"knowledge_bases", "creator_id", "deleted_at IS NULL AND is_temporary = false"},
"digital-humans": {"digital_humans", "owner_id", "deleted_at IS NULL"},
"flows": {"workbench_resources", "owner_id", "kind = 'flows'"},
}
func resources(tx *gorm.DB, t uint64) ([]ResourceView, error) {
rows := []ResourceView{}
for kind, spec := range resourceTables {
visibility := "false AS global"
if kind == "knowledge" {
visibility = "true AS global"
}
if kind == "agents" || kind == "skills" {
visibility = "(visibility = 'global' AND enabled = true) AS global"
}
part := []ResourceView{}
q := tx.Table(spec.table).Select("id,name,"+spec.owner+" AS owner_id,"+visibility).Where("tenant_id = ?", t).Where(spec.where)
if err := q.Order("name,id").Scan(&part).Error; err != nil {
return nil, err
}
for i := range part {
part[i].Kind = kind
}
rows = append(rows, part...)
}
var users []types.User
if err := tx.Select("id,username").Find(&users).Error; err != nil {
return nil, err
}
names := map[string]string{}
for _, u := range users {
names[u.ID] = u.Username
}
var pubs []Publication
if err := tx.Where("tenant_id = ?", t).Find(&pubs).Error; err != nil {
return nil, err
}
published := map[string]bool{}
for _, p := range pubs {
published[p.Kind+":"+p.ResourceID] = p.Global
}
for i := range rows {
rows[i].OwnerName = names[rows[i].OwnerID]
if global, managed := published[rows[i].Kind+":"+rows[i].ID]; managed {
rows[i].Global = global
}
}
return rows, nil
}
func (s *Service) ListResources(c *gin.Context) {
t, _ := identity(c.Request.Context())
rows, err := resources(s.db.WithContext(c.Request.Context()), t)
dbResult(c, err, rows)
}
func (s *Service) ListGrants(c *gin.Context) {
t, _ := identity(c.Request.Context())
rows := []Grant{}
err := s.db.Where("tenant_id = ?", t).Find(&rows).Error
dbResult(c, err, rows)
}
func (s *Service) UpdateGrants(c *gin.Context) {
var req struct {
Resources []struct {
Kind string `json:"kind"`
ID string `json:"id"`
} `json:"resources"`
UserIDs []string `json:"user_ids"`
Action string `json:"action"`
}
if c.ShouldBindJSON(&req) != nil || len(req.Resources) == 0 || len(req.Resources) > 500 || len(req.UserIDs) > 200 {
fail(c, 400, "请选择 1 至 500 个资源及不超过 200 个用户")
return
}
if req.Action != "grant" && req.Action != "revoke" && req.Action != "publish" && req.Action != "unpublish" {
fail(c, 400, "未知授权操作")
return
}
if (req.Action == "grant" || req.Action == "revoke") && len(req.UserIDs) == 0 {
fail(c, 400, "请选择授权用户")
return
}
t, _ := identity(c.Request.Context())
err := s.db.WithContext(c.Request.Context()).Transaction(func(tx *gorm.DB) error {
for _, id := range req.UserIDs {
var count int64
if e := tx.Table("users u").Joins("JOIN tenant_members m ON m.user_id=u.id AND m.tenant_id=? AND m.deleted_at IS NULL", t).Where("u.id=? AND u.deleted_at IS NULL AND u.is_system_admin=false AND u.can_access_all_tenants=false AND m.role IN ?", id, []string{"viewer", "contributor"}).Count(&count).Error; e != nil {
return e
}
if count != 1 {
return fmt.Errorf("%w: 授权用户不属于本空间或为管理员", errInvalid)
}
}
for _, r := range req.Resources {
spec, ok := resourceTables[r.Kind]
if !ok {
return errInvalid
}
var row struct{ ID string }
if e := tx.Table(spec.table).Select("id").Clauses(clause.Locking{Strength: "SHARE"}).Where("tenant_id=? AND id=?", t, r.ID).Where(spec.where).Take(&row).Error; e != nil {
return e
}
if req.Action == "publish" || req.Action == "unpublish" {
p := Publication{TenantID: t, Kind: r.Kind, ResourceID: r.ID, Global: req.Action == "publish", UpdatedAt: time.Now()}
if e := tx.Clauses(clause.OnConflict{Columns: []clause.Column{{Name: "tenant_id"}, {Name: "kind"}, {Name: "resource_id"}}, DoUpdates: clause.AssignmentColumns([]string{"global", "updated_at"})}).Create(&p).Error; e != nil {
return e
}
} else {
// First explicit grant opts an asset into per-user authorization.
if e := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&Publication{TenantID: t, Kind: r.Kind, ResourceID: r.ID}).Error; e != nil {
return e
}
for _, id := range req.UserIDs {
if req.Action == "grant" {
if e := tx.Clauses(clause.OnConflict{DoNothing: true}).Create(&Grant{TenantID: t, Kind: r.Kind, ResourceID: r.ID, UserID: id, UpdatedAt: time.Now()}).Error; e != nil {
return e
}
} else {
if e := tx.Where("tenant_id=? AND kind=? AND resource_id=? AND user_id=?", t, r.Kind, r.ID, id).Delete(&Grant{}).Error; e != nil {
return e
}
}
}
}
}
return s.audit(tx, c.Request.Context(), "access.resources_"+req.Action, "", map[string]any{"resources": req.Resources, "user_ids": req.UserIDs})
})
dbResult(c, err, true)
}
@@ -0,0 +1,34 @@
package accesscontrol
import (
"strings"
"testing"
)
func TestValidUserRequestPasswordPolicy(t *testing.T) {
cases := []struct {
name string
password string
create bool
want bool
}{
{"create three digits", "123", true, true},
{"create three Unicode characters", "三个字", true, true},
{"update three letters", "abc", false, true},
{"too few characters", "两字", true, false},
{"maximum characters", strings.Repeat("x", 32), true, true},
{"over maximum characters", strings.Repeat("x", 33), true, false},
{"bcrypt maximum bytes", strings.Repeat("字", 24), true, true},
{"over bcrypt bytes", strings.Repeat("字", 24) + "x", true, false},
{"create requires password", "", true, false},
{"update leaves existing password", "", false, true},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
req := userRequest{Username: "alice", Email: "alice@example.com", Role: "contributor", Status: "active", Password: tc.password}
if got := validUserRequest(req, tc.create); got != tc.want {
t.Fatalf("validUserRequest = %v, want %v", got, tc.want)
}
})
}
}
@@ -0,0 +1,39 @@
package accesscontrol
import (
"context"
"slices"
"testing"
"github.com/Tencent/WeKnora/internal/accesspolicy"
"github.com/Tencent/WeKnora/internal/types"
)
func TestVisiblePermissionsRespectNativeRole(t *testing.T) {
for _, role := range []types.TenantRole{types.TenantRoleViewer, types.TenantRoleContributor, types.TenantRoleAdmin} {
ctx := context.WithValue(context.Background(), types.TenantRoleContextKey, role)
permissions := visiblePermissions(ctx, &accesspolicy.Snapshot{})
if slices.Contains(permissions, "content.write") != role.HasPermission(types.TenantRoleContributor) {
t.Fatalf("content mutation visibility does not match native role %s", role)
}
if slices.Contains(permissions, "content.review") != role.HasPermission(types.TenantRoleContributor) {
t.Fatalf("content review visibility does not match native role %s", role)
}
if !slices.Contains(permissions, "content.deliver") {
t.Fatalf("delivery must be available to native readers: %s", role)
}
for _, permission := range []string{"access.read", "settings.read", "settings.write", "balance.read", "balance.write", "usage.read", "audit.read", "system.read"} {
if slices.Contains(permissions, permission) != role.HasPermission(types.TenantRoleAdmin) {
t.Fatalf("%s visibility does not match native role %s", permission, role)
}
}
if !slices.Contains(permissions, "chat.write") {
t.Fatalf("native viewer conversation permission must remain available for %s", role)
}
}
ctx := context.WithValue(context.Background(), types.TenantRoleContextKey, types.TenantRoleContributor)
permissions := visiblePermissions(ctx, &accesspolicy.Snapshot{Restricted: true, Permissions: map[string]bool{"content.read": true}})
if !slices.Equal(permissions, []string{"content.read"}) {
t.Fatalf("custom role broadened: %v", permissions)
}
}