@@ -66,6 +66,7 @@ func (h *Handlers) issuesDeps() issues.Deps { |
| 66 | Pool: h.d.Pool, | 66 | Pool: h.d.Pool, |
| 67 | Limiter: h.d.Limiter, | 67 | Limiter: h.d.Limiter, |
| 68 | Logger: h.d.Logger, | 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 | viewer := middleware.CurrentUserFromContext(r.Context()) | 333 | viewer := middleware.CurrentUserFromContext(r.Context()) |
| 333 | body := r.PostFormValue("body") | 334 | body := r.PostFormValue("body") |
| 334 | | 335 | |
| 335 | - // IsCollab — for v1 only the repo owner counts as collaborator | 336 | + // IsCollab is the locked-issue bypass: triage+ on the repo can comment |
| 336 | - // (S15 attaches collaborators table; lookup deferred for the | 337 | + // past a `locked=true` flag (the gate exists to silence drive-by |
| 337 | - // locked-issue gate). Owners always pass. | 338 | + // posters). We resolve the *real* role via the policy package — owner |
| 338 | - isCollab := row.OwnerUserID.Valid && row.OwnerUserID.Int64 == viewer.ID | 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 | _, err := issues.AddComment(r.Context(), h.issuesDeps(), issues.CommentCreateParams{ | 344 | _, err := issues.AddComment(r.Context(), h.issuesDeps(), issues.CommentCreateParams{ |
| 341 | IssueID: issue.ID, | 345 | IssueID: issue.ID, |
@@ -351,7 +355,12 @@ func (h *Handlers) issueComment(w http.ResponseWriter, r *http.Request) { |
| 351 | } | 355 | } |
| 352 | | 356 | |
| 353 | func (h *Handlers) issueSetState(w http.ResponseWriter, r *http.Request) { | 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 | if !ok { | 364 | if !ok { |
| 356 | return | 365 | return |
| 357 | } | 366 | } |
@@ -359,9 +368,18 @@ func (h *Handlers) issueSetState(w http.ResponseWriter, r *http.Request) { |
| 359 | if !ok { | 368 | if !ok { |
| 360 | return | 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 | state := strings.TrimSpace(r.PostFormValue("state")) | 381 | state := strings.TrimSpace(r.PostFormValue("state")) |
| 363 | reason := strings.TrimSpace(r.PostFormValue("reason")) | 382 | reason := strings.TrimSpace(r.PostFormValue("reason")) |
| 364 | - viewer := middleware.CurrentUserFromContext(r.Context()) | | |
| 365 | if err := issues.SetState(r.Context(), h.issuesDeps(), viewer.ID, issue.ID, state, reason); err != nil { | 383 | if err := issues.SetState(r.Context(), h.issuesDeps(), viewer.ID, issue.ID, state, reason); err != nil { |
| 366 | h.handleIssueWriteError(w, r, owner.Username, row, issue, err) | 384 | h.handleIssueWriteError(w, r, owner.Username, row, issue, err) |
| 367 | return | 385 | return |