tenseleyflow/shithub / 89bc712

Browse files

H1: author-self-close on issues and PRs via RepoRef.AuthorUserID

Authored by mfwolffe <wolffemf@dukes.jmu.edu>
SHA
89bc712beb05a242c1901b4a118c4e597e356b7f
Parents
6cb16b5
Tree
fdda0db

2 changed files

StatusFile+-
M internal/auth/policy/resources.go 15 0
M internal/web/handlers/repo/pulls.go 31 14
internal/auth/policy/resources.gomodified
@@ -18,6 +18,21 @@ type RepoRef struct {
18
 	Visibility  string // "public" | "private"
18
 	Visibility  string // "public" | "private"
19
 	IsArchived  bool
19
 	IsArchived  bool
20
 	IsDeleted   bool
20
 	IsDeleted   bool
21
+
22
+	// AuthorUserID is the author of the *issue or PR* being acted on,
23
+	// when the action is one that grants author-self privileges
24
+	// (`ActionIssueClose`, `ActionPullClose`). Zero means "no author
25
+	// context" — e.g. a repo-level read or write — and is the default.
26
+	// Handlers populate this only on the close paths; everywhere else
27
+	// it stays zero.
28
+	//
29
+	// This is a pragmatic v1 shape: instead of introducing an
30
+	// IssueRef/PullRef parallel to RepoRef, we widen RepoRef with the
31
+	// one fact the close gate needs. When/if more issue-level rules
32
+	// land (assignee privileges, labeler privileges) we'll graduate
33
+	// to a proper IssueRef. The audit captured the design tradeoff;
34
+	// don't add new fields here without re-reading that note.
35
+	AuthorUserID int64
21
 }
36
 }
22
 
37
 
23
 // IsPublic returns true when the repo's visibility column is "public".
38
 // IsPublic returns true when the repo's visibility column is "public".
internal/web/handlers/repo/pulls.gomodified
@@ -29,8 +29,12 @@ import (
29
 
29
 
30
 // MountPulls registers /{owner}/{repo}/pulls* routes. Reads are
30
 // MountPulls registers /{owner}/{repo}/pulls* routes. Reads are
31
 // public (subject to policy.Can(ActionPullRead)); writes require auth.
31
 // public (subject to policy.Can(ActionPullRead)); writes require auth.
32
-// The merge route enqueues a pr:merge worker job and renders a
32
+// The merge route runs synchronously inside the request: pulls.Merge
33
-// "merging…" page; the worker performs the actual git operation.
33
+// performs the worktree operation, updates DB state, and the response
34
+// redirects the user straight to the merged view. (An async-merge path
35
+// can be reintroduced when very-large-repo merges become a real
36
+// concern; the worker registration was deleted alongside the unused
37
+// KindPRMerge in the audit remediation sprint.)
34
 func (h *Handlers) MountPulls(r chi.Router) {
38
 func (h *Handlers) MountPulls(r chi.Router) {
35
 	r.Get("/{owner}/{repo}/pulls", h.pullsList)
39
 	r.Get("/{owner}/{repo}/pulls", h.pullsList)
36
 	r.Get("/{owner}/{repo}/pulls/{number}", h.pullView)
40
 	r.Get("/{owner}/{repo}/pulls/{number}", h.pullView)
@@ -53,7 +57,7 @@ func (h *Handlers) MountPulls(r chi.Router) {
53
 }
57
 }
54
 
58
 
55
 func (h *Handlers) pullsDeps() pulls.Deps {
59
 func (h *Handlers) pullsDeps() pulls.Deps {
56
-	return pulls.Deps{Pool: h.d.Pool, Logger: h.d.Logger}
60
+	return pulls.Deps{Pool: h.d.Pool, Logger: h.d.Logger, Audit: h.d.Audit}
57
 }
61
 }
58
 
62
 
59
 // pullsList renders /{owner}/{repo}/pulls.
63
 // pullsList renders /{owner}/{repo}/pulls.
@@ -472,6 +476,11 @@ func renderCheckSummary(raw []byte) template.HTML {
472
 	if err := json.Unmarshal(raw, &o); err != nil || o.Summary == "" {
476
 	if err := json.Unmarshal(raw, &o); err != nil || o.Summary == "" {
473
 		return ""
477
 		return ""
474
 	}
478
 	}
479
+	// Summary is bounded by the API's 256 KiB body cap (well under
480
+	// markdown's 1 MiB ceiling). An error here only fires if a
481
+	// structural precondition regresses; the function is a pure
482
+	// presenter so we degrade to empty (the caller is just rendering
483
+	// a tooltip-grade snippet on the PR checks panel).
475
 	html, _ := mdrender.RenderHTML([]byte(o.Summary))
484
 	html, _ := mdrender.RenderHTML([]byte(o.Summary))
476
 	return template.HTML(html) //nolint:gosec // sanitized by bluemonday UGCPolicy
485
 	return template.HTML(html) //nolint:gosec // sanitized by bluemonday UGCPolicy
477
 }
486
 }
@@ -500,7 +509,11 @@ func (h *Handlers) pullEdit(w http.ResponseWriter, r *http.Request) {
500
 
509
 
501
 // pullSetState handles POST .../state.
510
 // pullSetState handles POST .../state.
502
 func (h *Handlers) pullSetState(w http.ResponseWriter, r *http.Request) {
511
 func (h *Handlers) pullSetState(w http.ResponseWriter, r *http.Request) {
503
-	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullClose)
512
+	// Two-pass authorization: read access first, then ActionPullClose
513
+	// with `repo.AuthorUserID = pr.AuthorUserID` set so the policy engine
514
+	// grants author-self-close. Without the second pass, a non-collab
515
+	// fork-PR author couldn't close their own PR (S00-S25 audit, H1).
516
+	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullRead)
504
 	if !ok {
517
 	if !ok {
505
 		return
518
 		return
506
 	}
519
 	}
@@ -508,13 +521,22 @@ func (h *Handlers) pullSetState(w http.ResponseWriter, r *http.Request) {
508
 	if !ok {
521
 	if !ok {
509
 		return
522
 		return
510
 	}
523
 	}
524
+	viewer := middleware.CurrentUserFromContext(r.Context())
525
+	actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false)
526
+	repoRef := policy.NewRepoRefFromRepo(row)
527
+	if pr.IAuthorUserID.Valid {
528
+		repoRef.AuthorUserID = pr.IAuthorUserID.Int64
529
+	}
530
+	if dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionPullClose, repoRef); !dec.Allow {
531
+		h.d.Render.HTTPError(w, r, policy.Maybe404(dec, repoRef, actor), "")
532
+		return
533
+	}
511
 	gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
534
 	gitDir, err := h.d.RepoFS.RepoPath(owner.Username, row.Name)
512
 	if err != nil {
535
 	if err != nil {
513
 		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
536
 		h.d.Render.HTTPError(w, r, http.StatusNotFound, "")
514
 		return
537
 		return
515
 	}
538
 	}
516
 	state := strings.TrimSpace(r.PostFormValue("state"))
539
 	state := strings.TrimSpace(r.PostFormValue("state"))
517
-	viewer := middleware.CurrentUserFromContext(r.Context())
518
 	if err := pulls.SetState(r.Context(), h.pullsDeps(), gitDir, viewer.ID, pr.IID, state); err != nil {
540
 	if err := pulls.SetState(r.Context(), h.pullsDeps(), gitDir, viewer.ID, pr.IID, state); err != nil {
519
 		h.handlePullWriteError(w, r, err)
541
 		h.handlePullWriteError(w, r, err)
520
 		return
542
 		return
@@ -541,9 +563,10 @@ func (h *Handlers) pullSetReady(w http.ResponseWriter, r *http.Request) {
541
 }
563
 }
542
 
564
 
543
 // pullMerge handles POST .../merge. Performs the merge synchronously
565
 // pullMerge handles POST .../merge. Performs the merge synchronously
544
-// (so the redirect lands on the merged state). Heavy merges could be
566
+// inside the request so the redirect lands on the merged state. The
545
-// async via the pr:merge job; v1 is synchronous so the user sees an
567
+// pulls.Merge orchestrator updates repos.default_branch_oid in the
546
-// immediate result on small merges.
568
+// same tx when the base IS the default branch, since update-ref
569
+// bypasses the push:process hook that normally maintains the column.
547
 func (h *Handlers) pullMerge(w http.ResponseWriter, r *http.Request) {
570
 func (h *Handlers) pullMerge(w http.ResponseWriter, r *http.Request) {
548
 	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullMerge)
571
 	row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionPullMerge)
549
 	if !ok {
572
 	if !ok {
@@ -582,12 +605,6 @@ func (h *Handlers) pullMerge(w http.ResponseWriter, r *http.Request) {
582
 		h.handlePullWriteError(w, r, err)
605
 		h.handlePullWriteError(w, r, err)
583
 		return
606
 		return
584
 	}
607
 	}
585
-	// After merge, push:process won't fire (the update-ref bypassed
586
-	// the hook). Trigger a default-branch-OID refresh manually.
587
-	go func() {
588
-		// Fire-and-forget; the user is already redirected.
589
-		// Failure is logged but doesn't affect UX.
590
-	}()
591
 	h.redirectPull(w, r, owner.Username, row.Name, pr.INumber)
608
 	h.redirectPull(w, r, owner.Username, row.Name, pr.INumber)
592
 }
609
 }
593
 
610