diff --git a/internal/api/authz_scope_test.go b/internal/api/authz_scope_test.go new file mode 100644 index 0000000..1b0cd58 --- /dev/null +++ b/internal/api/authz_scope_test.go @@ -0,0 +1,181 @@ +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) + } + }) + } +}