fix(api): assert edit rights before ledger, then project body
Prevent fact-source writes ahead of ledger commit; projection failures return DOCUMENT_PROJECTION_FAILED while keeping the ledger authoritative. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -61,9 +61,7 @@ func (s *Server) authorizeDocRevision(ctx context.Context, user *auth.UserAccoun
|
||||
return authz.ErrForbidden
|
||||
}
|
||||
if ref.Kind == "orphan" {
|
||||
if write {
|
||||
return authz.ErrForbidden
|
||||
}
|
||||
// Unknown doc IDs: never bootstrap a silent fake ledger (read or write).
|
||||
return docrevision.ErrNotFound
|
||||
}
|
||||
if ref.ProjectID > 0 {
|
||||
@@ -143,6 +141,9 @@ func (s *Server) bootstrapRevisionIfNeeded(ctx context.Context, user *auth.UserA
|
||||
if !errors.Is(err, docrevision.ErrNotFound) {
|
||||
return nil, err
|
||||
}
|
||||
if ref != nil && ref.Kind == "orphan" {
|
||||
return nil, docrevision.ErrNotFound
|
||||
}
|
||||
md := ""
|
||||
if ref != nil {
|
||||
md = ref.Markdown
|
||||
@@ -158,9 +159,6 @@ func (s *Server) bootstrapRevisionIfNeeded(ctx context.Context, user *auth.UserA
|
||||
if ref.Kind != "" {
|
||||
kind = ref.Kind
|
||||
}
|
||||
if ref.Kind == "orphan" {
|
||||
kind = "freeform"
|
||||
}
|
||||
life = ref.LifecycleState
|
||||
projectID = ref.ProjectID
|
||||
workDir = ref.WorkDir
|
||||
|
||||
@@ -57,16 +57,16 @@ func TestDocRevision_UnauthenticatedAndUnknownOrphan(t *testing.T) {
|
||||
patch.Header.Set("Idempotency-Key", "orphan-k1")
|
||||
w := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w, patch)
|
||||
if w.Code != http.StatusForbidden {
|
||||
t.Fatalf("unknown orphan PATCH want 403 got %d %s", w.Code, w.Body.String())
|
||||
if w.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown orphan PATCH want 404 got %d %s", w.Code, w.Body.String())
|
||||
}
|
||||
|
||||
dec := httptest.NewRequest(http.MethodPost, "/api/v1/documents/doc-upload-unknown/agent-candidates/1/decision", bytes.NewReader([]byte(`{"decision":"reject"}`)))
|
||||
withBearerAuth(dec, token)
|
||||
w2 := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w2, dec)
|
||||
if w2.Code != http.StatusForbidden {
|
||||
t.Fatalf("unknown orphan decision want 403 got %d %s", w2.Code, w2.Body.String())
|
||||
if w2.Code != http.StatusNotFound {
|
||||
t.Fatalf("unknown orphan decision want 404 got %d %s", w2.Code, w2.Body.String())
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -188,20 +188,20 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
|
||||
projectID = ref.ProjectID
|
||||
kind = ref.Kind
|
||||
}
|
||||
if kind == "orphan" {
|
||||
writeDocRevisionError(w, docrevision.ErrNotFound)
|
||||
return
|
||||
}
|
||||
|
||||
// 1) Controlled: author duty + lease WITHOUT writing body (fail-closed before ledger).
|
||||
if kind == "controlled" && s.svc != nil && s.svc.DocGov() != nil {
|
||||
if _, err := s.svc.DocGov().SaveDocumentContent(r.Context(), docgov.ContentSaveRequest{
|
||||
DocID: docID,
|
||||
ActorID: user.ID,
|
||||
ActorRole: docgov.RoleAuthor,
|
||||
ActorName: user.Name,
|
||||
MarkdownBody: body.Markdown,
|
||||
}); err != nil {
|
||||
if err := s.svc.DocGov().AssertHumanCanEdit(r.Context(), docID, user.ID, docgov.RoleAuthor); err != nil {
|
||||
writeDocGovError(w, err)
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
// 2) Bootstrap if needed, then commit ledger. No fact-source mutation yet.
|
||||
if _, stErr := rev.GetRevisionState(r.Context(), docID); stErr != nil {
|
||||
md := body.Markdown
|
||||
if ref != nil && strings.TrimSpace(ref.Markdown) != "" {
|
||||
@@ -224,7 +224,7 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
|
||||
DocID: docID, Markdown: body.Markdown, ExpectedHeadSnapshot: expectedHead, ExpectedRevisionNo: expectedRev,
|
||||
IdempotencyKey: idemp, EditSessionID: body.EditSessionID, ActorUserID: user.ID,
|
||||
ActorSnapshot: docrevision.ActorSnapshot{DisplayName: user.Name, Role: "author", Duty: "author"},
|
||||
LifecycleState: lifecycle, WorkDir: workDir, ProjectID: projectID, AllowCreateBaseline: true,
|
||||
LifecycleState: lifecycle, WorkDir: workDir, ProjectID: projectID, AllowCreateBaseline: false,
|
||||
ClientBlockMap: body.ClientBlockMap,
|
||||
})
|
||||
if errors.Is(err, docrevision.ErrHeadConflict) && res != nil && res.Conflict != nil {
|
||||
@@ -237,14 +237,27 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
|
||||
writeDocRevisionError(w, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 3) Project fact-source AFTER ledger commit. Projection failure returns error (no fake 200).
|
||||
// Ledger remains authoritative; client should retry projection via re-save or admin repair.
|
||||
if kind == "controlled" || workDir != "" {
|
||||
if err := rev.ProjectOutputMarkdown(docID, s.dataRoot()); err != nil {
|
||||
writeDocRevisionError(w, err)
|
||||
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
|
||||
"code": "DOCUMENT_PROJECTION_FAILED",
|
||||
"error": err.Error(),
|
||||
"hint": "账本已提交但工作区投影失败;请重试保存。账本 head 仍为权威。",
|
||||
"state": res.State,
|
||||
})
|
||||
return
|
||||
}
|
||||
if s.svc != nil && s.svc.DocGov() != nil {
|
||||
if err := s.svc.DocGov().MarkDocumentContentUpdated(r.Context(), docID, res.State.HeadSHA256, false); err != nil {
|
||||
writeDocGovError(w, err)
|
||||
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
|
||||
"code": "DOCUMENT_PROJECTION_FAILED",
|
||||
"error": err.Error(),
|
||||
"hint": "账本已提交但 document_records 哈希投影失败;请重试保存。",
|
||||
"state": res.State,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
@@ -255,7 +268,12 @@ func (s *Server) handleDocRevisionPatchContent(w http.ResponseWriter, r *http.Re
|
||||
ContentMarkdown: &md,
|
||||
ChangeSummary: "revision ledger human save",
|
||||
}); err != nil {
|
||||
writeJSONResponse(w, http.StatusConflict, map[string]any{"error": err.Error()})
|
||||
writeJSONResponse(w, http.StatusInternalServerError, map[string]any{
|
||||
"code": "DOCUMENT_PROJECTION_FAILED",
|
||||
"error": err.Error(),
|
||||
"hint": "账本已提交但自由文档正文投影失败;请重试保存。账本 head 仍为权威。",
|
||||
"state": res.State,
|
||||
})
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package server
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strconv"
|
||||
"testing"
|
||||
|
||||
"chat-agent-system/internal/docrevision"
|
||||
)
|
||||
|
||||
func TestDocRevision_PatchOrder_AssertBeforeLedger_ProjectionFailureKeepsLedger(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
dataRoot := srv.dataRoot()
|
||||
|
||||
// WorkDir whose parent is a regular file → MkdirAll fails after ledger commit.
|
||||
blocker := filepath.Join(dataRoot, "proj-block")
|
||||
if err := os.WriteFile(blocker, []byte("not-a-dir"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
workDir := filepath.Join(blocker, "ws-proj")
|
||||
docID := "doc_proj1_patch_order"
|
||||
|
||||
if _, err := srv.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 1, "admin", 2, "r", 3, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := srv.svc.DocRevision().OpenTemplateVersion(ctx, docID, "# base\n\nbody\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
beforeRev := st.HeadRevisionNo
|
||||
beforeSnap := st.HeadSnapshotID
|
||||
|
||||
token := loginTestAdmin(t, srv)
|
||||
body := `{"markdown":"# base\n\nbody\n\nedited by human\n"}`
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content", bytes.NewReader([]byte(body)))
|
||||
withBearerAuth(req, token)
|
||||
req.Header.Set("Idempotency-Key", "patch-order-k1")
|
||||
req.Header.Set("If-Match", `"`+strconv.FormatInt(st.HeadSnapshotID, 10)+":"+strconv.Itoa(st.HeadRevisionNo)+`"`)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w, req)
|
||||
|
||||
if w.Code != http.StatusInternalServerError {
|
||||
t.Fatalf("projection failure want 500 got %d body=%s", w.Code, w.Body.String())
|
||||
}
|
||||
var payload map[string]any
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if payload["code"] != "DOCUMENT_PROJECTION_FAILED" {
|
||||
t.Fatalf("code want DOCUMENT_PROJECTION_FAILED got %#v", payload["code"])
|
||||
}
|
||||
after, err := srv.svc.DocRevision().GetRevisionState(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.HeadRevisionNo <= beforeRev && after.HeadSnapshotID == beforeSnap {
|
||||
t.Fatalf("ledger should commit before projection; before rev=%d snap=%d after=%+v", beforeRev, beforeSnap, after)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDocRevision_PatchOrder_LeaseDeniedDoesNotAdvanceLedger(t *testing.T) {
|
||||
srv := newTestServer(t)
|
||||
ctx := context.Background()
|
||||
docID := "doc_proj1_lease_denied"
|
||||
workDir := filepath.Join(srv.dataRoot(), "ws-lease")
|
||||
_ = os.MkdirAll(workDir, 0o755)
|
||||
|
||||
// Author is user 99; login admin may bypass route author gate but AssertHumanCanEdit (RoleAuthor) still fails.
|
||||
if _, err := srv.svc.DocGov().CreateOrGetDocument(ctx, docID, 1, "intake", "摸底", "V0.1", workDir, 99, "other", 2, "r", 3, "a"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
st, err := srv.svc.DocRevision().OpenTemplateVersion(ctx, docID, "# L\n\nx\n", docrevision.ActorSnapshot{DisplayName: "t"}, 1, workDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := st.HeadRevisionNo
|
||||
|
||||
token := loginTestAdmin(t, srv)
|
||||
req := httptest.NewRequest(http.MethodPatch, "/api/v1/documents/"+docID+"/content",
|
||||
bytes.NewReader([]byte(`{"markdown":"# L\n\nchanged\n"}`)))
|
||||
withBearerAuth(req, token)
|
||||
req.Header.Set("Idempotency-Key", "lease-denied-k1")
|
||||
req.Header.Set("If-Match", `"`+strconv.FormatInt(st.HeadSnapshotID, 10)+":"+strconv.Itoa(st.HeadRevisionNo)+`"`)
|
||||
w := httptest.NewRecorder()
|
||||
srv.Routes().ServeHTTP(w, req)
|
||||
if w.Code == http.StatusOK {
|
||||
t.Fatalf("non-author must not save successfully: %s", w.Body.String())
|
||||
}
|
||||
after, err := srv.svc.DocRevision().GetRevisionState(ctx, docID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if after.HeadRevisionNo != before {
|
||||
t.Fatalf("ledger must not advance when AssertHumanCanEdit fails: before=%d after=%d", before, after.HeadRevisionNo)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user