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}) }) }