a2ca9c25d0
Part of the same security-hardening pass as the last four commits.
terdut-server's authz is already solid -- centralized predicates
(requireTeamMember, requireTeamOwner, requireSelfOrAdmin, AdminOnly)
rather than ad hoc per-handler checks, confirmed by spot-checking several
handlers while writing this. But it is enforced by convention, not the
type system: a future handler that forgets its guard would compile and
read fine on review, exactly like one that remembers it.
authz_scope_test.go builds two teams and, for every team-scoped route
(members, OIDC groups, invites, escalation, dead man's switches,
integrations, schedule, plus every /api/incidents/{id}/... route, scoped
by the incident's own team_id through incidentIDParam's single
chokepoint), calls it as one team's owner against the other team's
resources and asserts 404 -- requireTeamMember and requireTeamOwner both
answer a non-member that way. Separate tests cover AdminOnly's routes
(403 for a non-admin) and requireSelfOrAdmin's (403 for a non-admin
acting on someone else's account).
Verified the test actually catches a regression, not just that it
passes today: temporarily removed handleListTeamMembers' requireTeamMember
call, confirmed exactly that one subtest failed and nothing else did,
then put it back.
🤖 Generated with [Claude Code](https://claude.com/claude-code)
Co-Authored-By: Claude <noreply@anthropic.com>
182 lines
6.5 KiB
Go
182 lines
6.5 KiB
Go
package api_test
|
|
|
|
import (
|
|
"net/http"
|
|
"testing"
|
|
)
|
|
|
|
// This file is the regression test for the pattern documented throughout
|
|
// middleware.go: every team-scoped handler calls requireTeamMember or
|
|
// requireTeamOwner before touching data, every self-or-admin handler calls
|
|
// requireSelfOrAdmin, and every admin-only route sits behind AdminOnly. That
|
|
// pattern is enforced by convention, not by the type system — a new handler
|
|
// that forgets the call would compile and pass review on a quick read just
|
|
// as easily as one that remembers it. These tests exercise every route that
|
|
// carries one of those guards as a caller who should be refused, so a future
|
|
// handler missing its guard fails CI instead of becoming a silent IDOR.
|
|
|
|
// TestAuthzScope_TeamScopedRoutesRefuseANonMember builds two teams and, for
|
|
// every team-scoped route, calls it as team A's owner against team B's
|
|
// resources. requireTeamMember and requireTeamOwner both answer a non-member
|
|
// with 404 (team.go's own reasoning: whether a team exists is itself
|
|
// something only its members should learn), so every one of these must come
|
|
// back 404 regardless of which of the two guards its handler uses.
|
|
func TestAuthzScope_TeamScopedRoutesRefuseANonMember(t *testing.T) {
|
|
s := newTS(t)
|
|
a := newTeam(t, s, "authz-a")
|
|
b := newTeam(t, s, "authz-b")
|
|
|
|
// An incident in B, to cover the ID-based routes under /api/incidents —
|
|
// scoped by the incident's own team_id rather than a {teamID} path
|
|
// segment, but through the same single chokepoint (incidentIDParam).
|
|
postToIntegration(t, s, b.key, "fp-authz-scope", "AuthzScopeAlert")
|
|
var incidents []struct {
|
|
ID int64 `json:"id"`
|
|
}
|
|
decode(t, b.call(http.MethodGet, "/api/incidents", nil), &incidents)
|
|
if len(incidents) == 0 {
|
|
t.Fatal("setup: no incident in team B to test against")
|
|
}
|
|
incidentPath := "/api/incidents/" + id64(incidents[0].ID)
|
|
|
|
bPath := "/api/teams/" + id64(b.id)
|
|
tests := []struct {
|
|
method, path string
|
|
}{
|
|
// Team membership/ownership itself.
|
|
{http.MethodPut, bPath},
|
|
{http.MethodDelete, bPath},
|
|
{http.MethodGet, bPath + "/members"},
|
|
{http.MethodPost, bPath + "/members"},
|
|
{http.MethodDelete, bPath + "/members/1"},
|
|
|
|
// OIDC group binding.
|
|
{http.MethodGet, bPath + "/oidc-groups"},
|
|
{http.MethodPut, bPath + "/oidc-groups"},
|
|
|
|
// Invites.
|
|
{http.MethodGet, bPath + "/invites"},
|
|
{http.MethodPost, bPath + "/invites"},
|
|
{http.MethodDelete, bPath + "/invites/1"},
|
|
|
|
// Escalation.
|
|
{http.MethodGet, bPath + "/escalation"},
|
|
{http.MethodPut, bPath + "/escalation"},
|
|
|
|
// Dead man's switches.
|
|
{http.MethodGet, bPath + "/deadman/switches"},
|
|
{http.MethodPost, bPath + "/deadman/switches"},
|
|
{http.MethodPut, bPath + "/deadman/switches/1"},
|
|
{http.MethodDelete, bPath + "/deadman/switches/1"},
|
|
|
|
// Integrations.
|
|
{http.MethodGet, bPath + "/integrations"},
|
|
{http.MethodPost, bPath + "/integrations"},
|
|
{http.MethodPatch, bPath + "/integrations/1"},
|
|
{http.MethodDelete, bPath + "/integrations/1"},
|
|
|
|
// Schedule.
|
|
{http.MethodGet, bPath + "/schedule"},
|
|
{http.MethodPost, bPath + "/schedule"},
|
|
{http.MethodDelete, bPath + "/schedule/1"},
|
|
|
|
// Incidents, scoped by the incident's own team rather than a
|
|
// {teamID} segment.
|
|
{http.MethodGet, incidentPath},
|
|
{http.MethodGet, incidentPath + "/alerts"},
|
|
{http.MethodGet, incidentPath + "/timeline"},
|
|
{http.MethodGet, incidentPath + "/similar"},
|
|
{http.MethodPost, incidentPath + "/acknowledge"},
|
|
{http.MethodDelete, incidentPath + "/acknowledge"},
|
|
{http.MethodPost, incidentPath + "/resolve"},
|
|
{http.MethodPost, incidentPath + "/assign"},
|
|
{http.MethodPost, incidentPath + "/snooze"},
|
|
{http.MethodDelete, incidentPath + "/snooze"},
|
|
{http.MethodPost, incidentPath + "/archive"},
|
|
{http.MethodDelete, incidentPath + "/archive"},
|
|
{http.MethodPost, incidentPath + "/notes"},
|
|
{http.MethodDelete, incidentPath + "/notes/1"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
resp := a.call(tc.method, tc.path, nil)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusNotFound {
|
|
t.Errorf("status = %d, want %d (A is not a member of B)", resp.StatusCode, http.StatusNotFound)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAuthzScope_AdminOnlyRoutesRefuseANonAdmin exercises AdminOnly's group
|
|
// in router.go directly: a signed-in, non-admin caller gets 403 from every
|
|
// route in it, before any handler body runs.
|
|
func TestAuthzScope_AdminOnlyRoutesRefuseANonAdmin(t *testing.T) {
|
|
s := newTS(t)
|
|
a := newTeam(t, s, "authz-admin")
|
|
|
|
tests := []struct {
|
|
method, path string
|
|
}{
|
|
{http.MethodPost, "/api/users"},
|
|
{http.MethodDelete, "/api/users/1"},
|
|
{http.MethodPut, "/api/users/1/admin"},
|
|
{http.MethodPut, "/api/users/1/disabled"},
|
|
{http.MethodGet, "/api/admin/teams"},
|
|
{http.MethodGet, "/api/admin/teams/" + id64(a.id)},
|
|
{http.MethodGet, "/api/admin/settings"},
|
|
{http.MethodPut, "/api/admin/settings"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
resp := a.call(tc.method, tc.path, nil)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("status = %d, want %d (not an admin)", resp.StatusCode, http.StatusForbidden)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestAuthzScope_SelfOrAdminRoutesRefuseAnotherNonAdminUser exercises
|
|
// requireSelfOrAdmin's call sites: a non-admin caller acting on a *different*
|
|
// user's account must be refused, the same as AdminOnly's routes, even
|
|
// though these sit in the general authenticated group rather than behind
|
|
// AdminOnly itself.
|
|
func TestAuthzScope_SelfOrAdminRoutesRefuseAnotherNonAdminUser(t *testing.T) {
|
|
s := newTS(t)
|
|
a := newTeam(t, s, "authz-self-a")
|
|
b := newTeam(t, s, "authz-self-b")
|
|
|
|
var members []struct {
|
|
UserID int64 `json:"user_id"`
|
|
}
|
|
decode(t, s.req(t, http.MethodGet, "/api/teams/"+id64(b.id)+"/members", nil), &members)
|
|
if len(members) == 0 {
|
|
t.Fatal("setup: team B has no members")
|
|
}
|
|
bUserID := id64(members[0].UserID)
|
|
|
|
tests := []struct {
|
|
method, path string
|
|
}{
|
|
{http.MethodGet, "/api/users/" + bUserID + "/teams"},
|
|
{http.MethodPut, "/api/users/" + bUserID + "/notify"},
|
|
{http.MethodPut, "/api/users/" + bUserID + "/password"},
|
|
{http.MethodPost, "/api/users/" + bUserID + "/api-keys"},
|
|
{http.MethodDelete, "/api/users/" + bUserID + "/api-keys/1"},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.method+" "+tc.path, func(t *testing.T) {
|
|
resp := a.call(tc.method, tc.path, nil)
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusForbidden {
|
|
t.Errorf("status = %d, want %d (not self, not an admin)", resp.StatusCode, http.StatusForbidden)
|
|
}
|
|
})
|
|
}
|
|
}
|