WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
This commit is contained in:
@@ -0,0 +1,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")
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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(¤t).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
current.LeaseUntil = nil
|
||||
current.LeaseToken = ""
|
||||
if copyErr != nil {
|
||||
return s.failKnowledgeVersionJob(tx, ¤t, 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(¤t).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
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user