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:
@@ -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)
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user