fix(bizdev): commit squad ledger as the document author

Non-author triggers were denied by dutyAuthorizer because AutoCommitUserID was the triggering user. Record that user on OnBehalfOf and surface the duty mismatch in the task error.
This commit is contained in:
weichen
2026-09-28 01:24:25 +00:00
parent 774df05998
commit c713834954
3 changed files with 211 additions and 1 deletions
+14
View File
@@ -145,6 +145,20 @@ func TestIdempotentHumanEditSameDigest(t *testing.T) {
} }
} }
func TestDutyAuthorizerAllowsDocumentAuthorCommit(t *testing.T) {
rec := &docgov.DocumentRecord{DocID: "doc_flow_author_commit", AuthorID: 4, ApproverID: 9}
auth := DutyAuthorizer()
if err := auth.Authorize(context.Background(), Principal{UserID: 4}, rec, ActionCommit); err != nil {
t.Fatalf("document author must pass agent commit: %v", err)
}
if err := auth.Authorize(context.Background(), Principal{UserID: 74}, rec, ActionCommit); err != uow.ErrPermissionDenied {
t.Fatalf("non-author commit must be denied, got %v", err)
}
if err := auth.Authorize(context.Background(), Principal{UserID: 4}, rec, ActionEdit); err != nil {
t.Fatalf("document author must still pass edit: %v", err)
}
}
func TestReauthDeniesWrongAuthor(t *testing.T) { func TestReauthDeniesWrongAuthor(t *testing.T) {
ctx := context.Background() ctx := context.Background()
gov := docgov.NewGovernanceEngine(nil) gov := docgov.NewGovernanceEngine(nil)
+19 -1
View File
@@ -4,10 +4,12 @@ import (
"context" "context"
"crypto/sha256" "crypto/sha256"
"encoding/hex" "encoding/hex"
"errors"
"fmt" "fmt"
"strings" "strings"
"time" "time"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/docgov" "chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision" "chat-agent-system/internal/docrevision"
"chat-agent-system/internal/projectworkflow" "chat-agent-system/internal/projectworkflow"
@@ -27,6 +29,17 @@ func autoRepairForControlledSquad(reqAutoRepair bool, docID string, docRec *docg
return reqAutoRepair return reqAutoRepair
} }
// squadCommitActor 让受控账本提交通过编制人职责门。
// 触发者不是编制人时,以文档编制人作为 AutoCommitUserID,并把触发者写入 OnBehalfOf。
// 触发者或编制人缺失时保持原触发者,避免把 0 写成代理身份。
func squadCommitActor(triggerUserID int64, docRec *docgov.DocumentRecord) (int64, *int64) {
if docRec == nil || docRec.AuthorID <= 0 || triggerUserID <= 0 || docRec.AuthorID == triggerUserID {
return triggerUserID, nil
}
onBehalf := triggerUserID
return docRec.AuthorID, &onBehalf
}
func qualityFailedErrorReason(report *squad.QualityReport) string { func qualityFailedErrorReason(report *squad.QualityReport) string {
if report == nil { if report == nil {
return "质量门禁未通过" return "质量门禁未通过"
@@ -87,15 +100,20 @@ func (s *Service) finalizeSquadDeliverable(
// Squad may have already written candidate body to workDir/output.md — pending must restore ledger head. // Squad may have already written candidate body to workDir/output.md — pending must restore ledger head.
if s.docRev != nil && docRec != nil && strings.TrimSpace(docRec.DocID) != "" { if s.docRev != nil && docRec != nil && strings.TrimSpace(docRec.DocID) != "" {
actor := docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed} actor := docrevision.ActorSnapshot{SquadName: "bizdev-squad", Model: res.BackendUsed}
commitUserID, onBehalfOf := squadCommitActor(req.UserID, docRec)
mat, matErr := s.materializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{ mat, matErr := s.materializeAgentOutput(ctx, docrevision.AgentMaterializeRequest{
DocID: docID, RunID: fmt.Sprintf("bizdev-%d-%s", time.Now().UnixNano(), req.TaskKey), DocID: docID, RunID: fmt.Sprintf("bizdev-%d-%s", time.Now().UnixNano(), req.TaskKey),
ProposalID: appliedProposalID, OutputMarkdown: res.MarkdownBody, ProposalID: appliedProposalID, OutputMarkdown: res.MarkdownBody,
ProjectID: req.ProjectID, WorkDir: res.WorkDir, ProjectID: req.ProjectID, WorkDir: res.WorkDir,
ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: actor, ActorKind: docrevision.ActorAgentSquad, ActorSnapshot: actor,
AutoCommitUserID: req.UserID, OnBehalfOf: onBehalfOf,
AutoCommitUserID: commitUserID,
}) })
if matErr != nil { if matErr != nil {
reason := fmt.Sprintf("文档版本账本/变更报告落盘失败: %v", matErr) reason := fmt.Sprintf("文档版本账本/变更报告落盘失败: %v", matErr)
if errors.Is(matErr, uow.ErrPermissionDenied) {
reason = fmt.Sprintf("触发用户非文档编制人,账本提交被拒: %v", matErr)
}
return &TaskRunResult{ return &TaskRunResult{
TaskKey: res.TaskKey, TaskKey: res.TaskKey,
ArtifactTitle: res.ArtifactTitle, ArtifactTitle: res.ArtifactTitle,
+178
View File
@@ -0,0 +1,178 @@
package bizdev
import (
"context"
"errors"
"strings"
"testing"
"time"
"chat-agent-system/internal/app/docflow"
"chat-agent-system/internal/app/uow"
"chat-agent-system/internal/config"
"chat-agent-system/internal/docgov"
"chat-agent-system/internal/docrevision"
)
type recordingMaterializer struct {
inner AgentOutputMaterializer
got docrevision.AgentMaterializeRequest
err error
}
func (r *recordingMaterializer) MaterializeAgentOutput(ctx context.Context, req docrevision.AgentMaterializeRequest) (*docrevision.AgentMaterializeResult, error) {
r.got = req
if r.err != nil {
return nil, r.err
}
if r.inner == nil {
return nil, errors.New("recording materializer has no inner")
}
return r.inner.MaterializeAgentOutput(ctx, req)
}
func TestSquadCommitActor_UsesAuthorWhenTriggerDiffers(t *testing.T) {
author, behalf := squadCommitActor(74, &docgov.DocumentRecord{AuthorID: 4})
if author != 4 {
t.Fatalf("commit actor want 4 got %d", author)
}
if behalf == nil || *behalf != 74 {
t.Fatalf("on behalf want 74 got %v", behalf)
}
same, behalfSame := squadCommitActor(4, &docgov.DocumentRecord{AuthorID: 4})
if same != 4 || behalfSame != nil {
t.Fatalf("author trigger must not proxy: actor=%d behalf=%v", same, behalfSame)
}
missing, behalfMissing := squadCommitActor(74, &docgov.DocumentRecord{})
if missing != 74 || behalfMissing != nil {
t.Fatalf("unset author must keep trigger: actor=%d behalf=%v", missing, behalfMissing)
}
}
func TestExecuteTask_NonAuthorTriggerMaterializesAsAuthor(t *testing.T) {
svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})
ctx := context.Background()
const (
projectID = int64(6022)
authorID = int64(4)
triggerID = int64(74)
docID = "doc_proj6022_bd_intake"
)
if _, err := svc.docGov.CreateOrGetDocument(ctx, docID, projectID, "bd_intake", "用户初始需求收集表", "V0.1_triage", "", authorID, "admin", 0, "", authorID, "admin"); err != nil {
t.Fatal(err)
}
rec := &recordingMaterializer{inner: revMaterializer{rev: svc.docRev}}
svc.SetAgentMaterializer(rec)
res, err := svc.ExecuteTask(ctx, TaskRunRequest{
TaskKey: "intake", ProjectID: projectID, ProjectName: "测试项目",
CustomerName: "C", DeviceType: "医疗器械", Backend: "stub",
Timeout: 10 * time.Second, DocID: docID, UserID: triggerID,
})
if err != nil {
t.Fatalf("ExecuteTask: %v reason=%v", err, res)
}
if res == nil || !res.Success {
t.Fatalf("non-author trigger must succeed, got %#v", res)
}
if rec.got.AutoCommitUserID != authorID {
t.Fatalf("AutoCommitUserID want %d got %d", authorID, rec.got.AutoCommitUserID)
}
if rec.got.OnBehalfOf == nil || *rec.got.OnBehalfOf != triggerID {
t.Fatalf("OnBehalfOf want %d got %v", triggerID, rec.got.OnBehalfOf)
}
}
func TestExecuteTask_AuthorTriggerDoesNotSetOnBehalfOf(t *testing.T) {
svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})
ctx := context.Background()
const (
projectID = int64(6023)
authorID = int64(4)
docID = "doc_proj6023_bd_intake"
)
if _, err := svc.docGov.CreateOrGetDocument(ctx, docID, projectID, "bd_intake", "用户初始需求收集表", "V0.1_triage", "", authorID, "admin", 0, "", 0, ""); err != nil {
t.Fatal(err)
}
rec := &recordingMaterializer{inner: revMaterializer{rev: svc.docRev}}
svc.SetAgentMaterializer(rec)
res, err := svc.ExecuteTask(ctx, TaskRunRequest{
TaskKey: "intake", ProjectID: projectID, ProjectName: "P",
CustomerName: "C", DeviceType: "医疗器械", Backend: "stub",
Timeout: 10 * time.Second, DocID: docID, UserID: authorID,
})
if err != nil || res == nil || !res.Success {
t.Fatalf("author trigger failed: err=%v res=%#v", err, res)
}
if rec.got.AutoCommitUserID != authorID || rec.got.OnBehalfOf != nil {
t.Fatalf("author path changed: user=%d behalf=%v", rec.got.AutoCommitUserID, rec.got.OnBehalfOf)
}
}
func TestExecuteTask_NonAuthorPassesDutyAuthorizer(t *testing.T) {
svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})
ctx := context.Background()
const (
projectID = int64(6024)
authorID = int64(4)
triggerID = int64(74)
docID = "doc_proj6024_bd_intake"
)
if _, err := svc.docGov.CreateOrGetDocument(ctx, docID, projectID, "bd_intake", "用户初始需求收集表", "V0.1_triage", "", authorID, "admin", 0, "", 0, ""); err != nil {
t.Fatal(err)
}
svc.SetAgentMaterializer(&docflow.Commands{Gov: svc.docGov, Rev: svc.docRev, Auth: docflow.DutyAuthorizer()})
res, err := svc.ExecuteTask(ctx, TaskRunRequest{
TaskKey: "intake", ProjectID: projectID, ProjectName: "P",
CustomerName: "C", DeviceType: "医疗器械", Backend: "stub",
Timeout: 10 * time.Second, DocID: docID, UserID: triggerID,
})
if err != nil || res == nil || !res.Success {
t.Fatalf("duty authorizer must accept author-proxy commit: err=%v reason=%v", err, errorReason(res))
}
if strings.Contains(errorReason(res), "permission denied") {
t.Fatalf("permission denied leaked: %s", res.ErrorReason)
}
}
func TestExecuteTask_PermissionDeniedNamesNonAuthor(t *testing.T) {
svc := MustNewService(t, &config.Config{DataDir: t.TempDir(), AgentBackend: "stub"})
ctx := context.Background()
const docID = "doc_perm_denied"
if _, err := svc.docGov.CreateOrGetDocument(ctx, docID, 77, "research", "调研", "V0.5", "", 4, "admin", 0, "", 0, ""); err != nil {
t.Fatal(err)
}
svc.SetAgentMaterializer(&recordingMaterializer{err: uow.ErrPermissionDenied})
res, err := svc.ExecuteTask(ctx, TaskRunRequest{
TaskKey: "research", ProjectID: 77, ProjectName: "P",
CustomerName: "C", DeviceType: "医疗器械", Backend: "stub",
Timeout: 10 * time.Second, DocID: docID, UserID: 74,
})
if err == nil || res == nil || res.Success {
t.Fatalf("want visible permission failure, err=%v res=%#v", err, res)
}
if !strings.Contains(res.ErrorReason, "触发用户非文档编制人") {
t.Fatalf("reason must name non-author denial, got %q", res.ErrorReason)
}
if !errors.Is(err, uow.ErrPermissionDenied) {
t.Fatalf("sentinel must stay errors.Is compatible, got %v", err)
}
}
type revMaterializer struct {
rev *docrevision.Service
}
func (r revMaterializer) MaterializeAgentOutput(ctx context.Context, req docrevision.AgentMaterializeRequest) (*docrevision.AgentMaterializeResult, error) {
return r.rev.MaterializeAgentOutput(ctx, req)
}
func errorReason(res *TaskRunResult) string {
if res == nil {
return ""
}
return res.ErrorReason
}