@@ -66,6 +66,7 @@ func (h *Handlers) issuesDeps() issues.Deps { |
| 66 | 66 | Pool: h.d.Pool, |
| 67 | 67 | Limiter: h.d.Limiter, |
| 68 | 68 | Logger: h.d.Logger, |
| 69 | + Audit: h.d.Audit, |
| 69 | 70 | } |
| 70 | 71 | } |
| 71 | 72 | |
@@ -332,10 +333,13 @@ func (h *Handlers) issueComment(w http.ResponseWriter, r *http.Request) { |
| 332 | 333 | viewer := middleware.CurrentUserFromContext(r.Context()) |
| 333 | 334 | body := r.PostFormValue("body") |
| 334 | 335 | |
| 335 | | - // IsCollab — for v1 only the repo owner counts as collaborator |
| 336 | | - // (S15 attaches collaborators table; lookup deferred for the |
| 337 | | - // locked-issue gate). Owners always pass. |
| 338 | | - isCollab := row.OwnerUserID.Valid && row.OwnerUserID.Int64 == viewer.ID |
| 336 | + // IsCollab is the locked-issue bypass: triage+ on the repo can comment |
| 337 | + // past a `locked=true` flag (the gate exists to silence drive-by |
| 338 | + // posters). We resolve the *real* role via the policy package — owner |
| 339 | + // is implicit admin, and any explicit collaborator with role >= triage |
| 340 | + // passes. Read fails (DB miss, unknown role) fail closed via RoleNone. |
| 341 | + actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false) |
| 342 | + isCollab := policy.HasRoleAtLeast(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.NewRepoRefFromRepo(row), policy.RoleTriage) |
| 339 | 343 | |
| 340 | 344 | _, err := issues.AddComment(r.Context(), h.issuesDeps(), issues.CommentCreateParams{ |
| 341 | 345 | IssueID: issue.ID, |
@@ -351,7 +355,12 @@ func (h *Handlers) issueComment(w http.ResponseWriter, r *http.Request) { |
| 351 | 355 | } |
| 352 | 356 | |
| 353 | 357 | func (h *Handlers) issueSetState(w http.ResponseWriter, r *http.Request) { |
| 354 | | - row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueClose) |
| 358 | + // Two-pass authorization: read access first, then ActionIssueClose |
| 359 | + // with `repo.AuthorUserID = issue.AuthorUserID` set so the policy |
| 360 | + // engine grants author-self-close. Without the second pass, an |
| 361 | + // issue's reporter who isn't a triage collaborator couldn't close |
| 362 | + // their own thread — which the audit flagged (S00-S25, H1). |
| 363 | + row, owner, ok := h.loadRepoAndAuthorize(w, r, policy.ActionIssueRead) |
| 355 | 364 | if !ok { |
| 356 | 365 | return |
| 357 | 366 | } |
@@ -359,9 +368,18 @@ func (h *Handlers) issueSetState(w http.ResponseWriter, r *http.Request) { |
| 359 | 368 | if !ok { |
| 360 | 369 | return |
| 361 | 370 | } |
| 371 | + viewer := middleware.CurrentUserFromContext(r.Context()) |
| 372 | + actor := policy.UserActor(viewer.ID, viewer.Username, viewer.IsSuspended, false) |
| 373 | + repoRef := policy.NewRepoRefFromRepo(row) |
| 374 | + if issue.AuthorUserID.Valid { |
| 375 | + repoRef.AuthorUserID = issue.AuthorUserID.Int64 |
| 376 | + } |
| 377 | + if dec := policy.Can(r.Context(), policy.Deps{Pool: h.d.Pool}, actor, policy.ActionIssueClose, repoRef); !dec.Allow { |
| 378 | + h.d.Render.HTTPError(w, r, policy.Maybe404(dec, repoRef, actor), "") |
| 379 | + return |
| 380 | + } |
| 362 | 381 | state := strings.TrimSpace(r.PostFormValue("state")) |
| 363 | 382 | reason := strings.TrimSpace(r.PostFormValue("reason")) |
| 364 | | - viewer := middleware.CurrentUserFromContext(r.Context()) |
| 365 | 383 | if err := issues.SetState(r.Context(), h.issuesDeps(), viewer.ID, issue.ID, state, reason); err != nil { |
| 366 | 384 | h.handleIssueWriteError(w, r, owner.Username, row, issue, err) |
| 367 | 385 | return |