tenseleyflow/shithub / 03475a3

Browse files

orgs: deny suspended actors on org/team mutations (SR2 C4)

Pre-fix: S30/S31 wired org/team handlers to call orgs.IsOwner
directly without going through policy.Can. A suspended user listed
as an org owner could still invite, change roles, remove members,
accept invitations, create teams, add team members, and grant
team→repo access — bypassing the suspension gate every other
mutation surface enforces. Same shape as SR1 C1, new surface.

Lighter fix per SR2 sprint plan: short-circuit on viewer.IsSuspended
at every point of entry. Heavier fix (real policy.Can actions for
ActionOrgInvite/ActionTeamMemberAdd/...) is correct architecturally
but is a larger refactor; left as forward-link.

Gated:
- requireOrgOwner — covers teamCreate, teamMemberAddRemove,
teamRepoGrant via the existing helper.
- invite — direct IsOwner caller, gated inline.
- memberMutate — direct IsOwner caller, gated inline (covers
changeRole + removeMember).
- invitationAction — accept/decline now denies suspended users.

Read-only IsOwner callers (teamsList, teamView, canSeeTeam,
filterSecretTeams) intentionally NOT gated — suspension blocks
writes, not reads (consistent with SR1).
Authored by espadonne
SHA
03475a3d5219dfe818d66600322b6a7322adc95e
Parents
d30741e
Tree
f85137c

2 changed files

StatusFile+-
M internal/web/handlers/orgs/orgs.go 21 0
M internal/web/handlers/orgs/teams.go 9 0
internal/web/handlers/orgs/orgs.gomodified
@@ -213,6 +213,14 @@ func (h *Handlers) invite(w http.ResponseWriter, r *http.Request) {
213213
 		h.d.Render.HTTPError(w, r, http.StatusUnauthorized, "")
214214
 		return
215215
 	}
216
+	// Suspended owners are denied with the same 403 as non-owners
217
+	// (SR2 C4). Org/team mutations don't currently route through
218
+	// policy.Can; this short-circuit mirrors the suspended-actor
219
+	// gate every other write surface enforces.
220
+	if viewer.IsSuspended {
221
+		h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
222
+		return
223
+	}
216224
 	owner, err := orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
217225
 	if err != nil || !owner {
218226
 		h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
@@ -270,6 +278,11 @@ func (h *Handlers) memberMutate(w http.ResponseWriter, r *http.Request, action f
270278
 		h.d.Render.HTTPError(w, r, http.StatusUnauthorized, "")
271279
 		return
272280
 	}
281
+	// Suspended owners denied like non-owners (SR2 C4).
282
+	if viewer.IsSuspended {
283
+		h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
284
+		return
285
+	}
273286
 	owner, _ := orgs.IsOwner(r.Context(), h.deps(), org.ID, viewer.ID)
274287
 	if !owner {
275288
 		h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
@@ -328,6 +341,14 @@ func (h *Handlers) invitationAction(w http.ResponseWriter, r *http.Request, acce
328341
 		http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
329342
 		return
330343
 	}
344
+	// Suspended users can't act on invitations either way (SR2 C4).
345
+	// Joining an org while suspended would let them participate in
346
+	// org-scoped actions; declining is harmless but the consistent
347
+	// gate makes the surface uniform.
348
+	if viewer.IsSuspended {
349
+		h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
350
+		return
351
+	}
331352
 	tok := chi.URLParam(r, "token")
332353
 	inv, err := orgs.LookupInvitationByToken(r.Context(), h.deps(), tok)
333354
 	if err != nil {
internal/web/handlers/orgs/teams.gomodified
@@ -212,6 +212,15 @@ func (h *Handlers) requireOrgOwner(w http.ResponseWriter, r *http.Request, orgID
212212
 		http.Redirect(w, r, "/login?next="+r.URL.Path, http.StatusSeeOther)
213213
 		return false
214214
 	}
215
+	// Suspended actors get the same 403 as non-owners. Mirrors the
216
+	// suspended gate the policy package enforces on every other
217
+	// mutation surface — this gate doesn't go through policy.Can yet
218
+	// (the org/team actions aren't in the policy enum), so we
219
+	// short-circuit here (SR2 C4). Same shape as SR1 C1 fix.
220
+	if viewer.IsSuspended {
221
+		h.d.Render.HTTPError(w, r, http.StatusForbidden, "")
222
+		return false
223
+	}
215224
 	owner, _ := orgs.IsOwner(r.Context(), h.deps(), orgID, viewer.ID)
216225
 	if !owner {
217226
 		h.d.Render.HTTPError(w, r, http.StatusForbidden, "")